diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..7af97f1 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "link-arg=-fuse-ld=mold"] diff --git a/.sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json b/.sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json new file mode 100644 index 0000000..15ba9ce --- /dev/null +++ b/.sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT t.token FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc" +} diff --git a/.sqlx/query-0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107.json b/.sqlx/query-0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107.json new file mode 100644 index 0000000..734848a --- /dev/null +++ b/.sqlx/query-0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET deactivated_at = $1 WHERE did = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Timestamptz", + "Text" + ] + }, + "nullable": [] + }, + "hash": "0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107" +} diff --git a/.sqlx/query-0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99.json b/.sqlx/query-0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99.json new file mode 100644 index 0000000..9e5e897 --- /dev/null +++ b/.sqlx/query-0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "body", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99" +} diff --git a/.sqlx/query-24a7686c535e4f0332f45daa20cfce2209635090252ac3692823450431d03dc6.json b/.sqlx/query-24a7686c535e4f0332f45daa20cfce2209635090252ac3692823450431d03dc6.json new file mode 100644 index 0000000..cf39684 --- /dev/null +++ b/.sqlx/query-24a7686c535e4f0332f45daa20cfce2209635090252ac3692823450431d03dc6.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM comms_queue WHERE status = 'pending' AND user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "24a7686c535e4f0332f45daa20cfce2209635090252ac3692823450431d03dc6" +} diff --git a/.sqlx/query-29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5.json b/.sqlx/query-29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5.json new file mode 100644 index 0000000..8d0d055 --- /dev/null +++ b/.sqlx/query-29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET password_reset_code_expires_at = NOW() - INTERVAL '1 hour' WHERE email = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5" +} diff --git a/.sqlx/query-6df413951ea7648c77d8db2fe6e704370869816a3f47c86671dfe000b5961eee.json b/.sqlx/query-2cbfa3c83222c1f67befdbfda687149e7a9af715957182954fbc28bf000b329f.json similarity index 78% rename from .sqlx/query-6df413951ea7648c77d8db2fe6e704370869816a3f47c86671dfe000b5961eee.json rename to .sqlx/query-2cbfa3c83222c1f67befdbfda687149e7a9af715957182954fbc28bf000b329f.json index 2212863..eddf46d 100644 --- a/.sqlx/query-6df413951ea7648c77d8db2fe6e704370869816a3f47c86671dfe000b5961eee.json +++ b/.sqlx/query-2cbfa3c83222c1f67befdbfda687149e7a9af715957182954fbc28bf000b329f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, did, handle, email, created_at, invites_disabled, email_verified, deactivated_at\n FROM users\n WHERE did = $1\n ", + "query": "\n SELECT id, did, handle, email, created_at, invites_disabled, email_verified, deactivated_at\n FROM users\n WHERE did = ANY($1)\n ", "describe": { "columns": [ { @@ -46,7 +46,7 @@ ], "parameters": { "Left": [ - "Text" + "TextArray" ] }, "nullable": [ @@ -60,5 +60,5 @@ true ] }, - "hash": "6df413951ea7648c77d8db2fe6e704370869816a3f47c86671dfe000b5961eee" + "hash": "2cbfa3c83222c1f67befdbfda687149e7a9af715957182954fbc28bf000b329f" } diff --git a/.sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json b/.sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json new file mode 100644 index 0000000..7004d69 --- /dev/null +++ b/.sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json @@ -0,0 +1,54 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT subject, body, comms_type as \"comms_type: String\" FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' ORDER BY created_at DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "subject", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "body", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "comms_type: String", + "type_info": { + "Custom": { + "name": "comms_type", + "kind": { + "Enum": [ + "welcome", + "email_verification", + "password_reset", + "email_update", + "account_deletion", + "admin_email", + "plc_operation", + "two_factor_code", + "channel_verification", + "passkey_recovery", + "legacy_login_alert", + "migration_verification" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true, + false, + false + ] + }, + "hash": "4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe" +} diff --git a/.sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json b/.sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json new file mode 100644 index 0000000..b81fee7 --- /dev/null +++ b/.sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM users WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068" +} diff --git a/.sqlx/query-4649e8daefaf4cfefc5cb2de8b3813f13f5892f653128469be727b686e6a0f0a.json b/.sqlx/query-4649e8daefaf4cfefc5cb2de8b3813f13f5892f653128469be727b686e6a0f0a.json new file mode 100644 index 0000000..5c9872f --- /dev/null +++ b/.sqlx/query-4649e8daefaf4cfefc5cb2de8b3813f13f5892f653128469be727b686e6a0f0a.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT body, metadata FROM comms_queue WHERE user_id = $1 AND comms_type = 'channel_verification' ORDER BY created_at DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "body", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "4649e8daefaf4cfefc5cb2de8b3813f13f5892f653128469be727b686e6a0f0a" +} diff --git a/.sqlx/query-47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88.json b/.sqlx/query-47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88.json new file mode 100644 index 0000000..e232907 --- /dev/null +++ b/.sqlx/query-47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT token, expires_at FROM account_deletion_requests WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88" +} diff --git a/.sqlx/query-49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6.json b/.sqlx/query-49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6.json new file mode 100644 index 0000000..873f0f7 --- /dev/null +++ b/.sqlx/query-49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email_verified FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email_verified", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6" +} diff --git a/.sqlx/query-5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0.json b/.sqlx/query-5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0.json new file mode 100644 index 0000000..32bd1aa --- /dev/null +++ b/.sqlx/query-5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT k.key_bytes, k.encryption_version\n FROM user_keys k\n JOIN users u ON k.user_id = u.id\n WHERE u.did = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "key_bytes", + "type_info": "Bytea" + }, + { + "ordinal": 1, + "name": "encryption_version", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0" +} diff --git a/.sqlx/query-5a036d95feedcbe6fb6396b10a7b4bd6a2eedeefda46a23e6a904cdbc3a65d45.json b/.sqlx/query-5a036d95feedcbe6fb6396b10a7b4bd6a2eedeefda46a23e6a904cdbc3a65d45.json new file mode 100644 index 0000000..296bb04 --- /dev/null +++ b/.sqlx/query-5a036d95feedcbe6fb6396b10a7b4bd6a2eedeefda46a23e6a904cdbc3a65d45.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT body FROM comms_queue WHERE user_id = $1 AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "body", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "5a036d95feedcbe6fb6396b10a7b4bd6a2eedeefda46a23e6a904cdbc3a65d45" +} diff --git a/.sqlx/query-65eece4401fa4cf08d7f09c3f0f0fdc32722423772cb6d11123aae9f2e0784d0.json b/.sqlx/query-65eece4401fa4cf08d7f09c3f0f0fdc32722423772cb6d11123aae9f2e0784d0.json new file mode 100644 index 0000000..8a1b45c --- /dev/null +++ b/.sqlx/query-65eece4401fa4cf08d7f09c3f0f0fdc32722423772cb6d11123aae9f2e0784d0.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT icu.used_by_user, icu.code\n FROM invite_code_uses icu\n WHERE icu.used_by_user = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "used_by_user", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "code", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "65eece4401fa4cf08d7f09c3f0f0fdc32722423772cb6d11123aae9f2e0784d0" +} diff --git a/.sqlx/query-785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json b/.sqlx/query-785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json new file mode 100644 index 0000000..af80ee3 --- /dev/null +++ b/.sqlx/query-785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT subject FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' AND body = 'Email without subject' LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "subject", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f" +} diff --git a/.sqlx/query-7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631.json b/.sqlx/query-7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631.json new file mode 100644 index 0000000..416f681 --- /dev/null +++ b/.sqlx/query-7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT t.token\n FROM plc_operation_tokens t\n JOIN users u ON t.user_id = u.id\n WHERE u.did = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631" +} diff --git a/.sqlx/query-82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81.json b/.sqlx/query-82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81.json new file mode 100644 index 0000000..8d5a9b7 --- /dev/null +++ b/.sqlx/query-82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT t.token, t.expires_at FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81" +} diff --git a/.sqlx/query-9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5.json b/.sqlx/query-9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5.json new file mode 100644 index 0000000..ef52899 --- /dev/null +++ b/.sqlx/query-9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "body", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5" +} diff --git a/.sqlx/query-9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002.json b/.sqlx/query-9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002.json new file mode 100644 index 0000000..178bc9e --- /dev/null +++ b/.sqlx/query-9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT did, public_key_did_key FROM reserved_signing_keys WHERE public_key_did_key = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "public_key_did_key", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + false + ] + }, + "hash": "9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002" +} diff --git a/.sqlx/query-9e772a967607553a0ab800970eaeadcaab7e06bdb79e0c89eb919b1bc1d6fabe.json b/.sqlx/query-9e772a967607553a0ab800970eaeadcaab7e06bdb79e0c89eb919b1bc1d6fabe.json new file mode 100644 index 0000000..8bcc4f5 --- /dev/null +++ b/.sqlx/query-9e772a967607553a0ab800970eaeadcaab7e06bdb79e0c89eb919b1bc1d6fabe.json @@ -0,0 +1,108 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id, user_id, recipient, subject, body,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\"\n FROM comms_queue\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "recipient", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "subject", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "body", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "channel: CommsChannel", + "type_info": { + "Custom": { + "name": "comms_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "comms_type: CommsType", + "type_info": { + "Custom": { + "name": "comms_type", + "kind": { + "Enum": [ + "welcome", + "email_verification", + "password_reset", + "email_update", + "account_deletion", + "admin_email", + "plc_operation", + "two_factor_code", + "channel_verification", + "passkey_recovery", + "legacy_login_alert", + "migration_verification" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "status: CommsStatus", + "type_info": { + "Custom": { + "name": "comms_status", + "kind": { + "Enum": [ + "pending", + "processing", + "sent", + "failed" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + false + ] + }, + "hash": "9e772a967607553a0ab800970eaeadcaab7e06bdb79e0c89eb919b1bc1d6fabe" +} diff --git a/.sqlx/query-a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5.json b/.sqlx/query-a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5.json new file mode 100644 index 0000000..2640f70 --- /dev/null +++ b/.sqlx/query-a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT private_key_bytes, expires_at, used_at FROM reserved_signing_keys WHERE public_key_did_key = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "private_key_bytes", + "type_info": "Bytea" + }, + { + "ordinal": 1, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5" +} diff --git a/.sqlx/query-a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5.json b/.sqlx/query-a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5.json new file mode 100644 index 0000000..f10b2bc --- /dev/null +++ b/.sqlx/query-a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT token FROM account_deletion_requests WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5" +} diff --git a/.sqlx/query-b0fca342e85dea89a06b4fee144cae4825dec587b1387f0fee401458aea2a2e5.json b/.sqlx/query-b0fca342e85dea89a06b4fee144cae4825dec587b1387f0fee401458aea2a2e5.json new file mode 100644 index 0000000..0297c9a --- /dev/null +++ b/.sqlx/query-b0fca342e85dea89a06b4fee144cae4825dec587b1387f0fee401458aea2a2e5.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n recipient, subject, body,\n comms_type as \"comms_type: CommsType\"\n FROM comms_queue\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "recipient", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "subject", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "body", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "comms_type: CommsType", + "type_info": { + "Custom": { + "name": "comms_type", + "kind": { + "Enum": [ + "welcome", + "email_verification", + "password_reset", + "email_update", + "account_deletion", + "admin_email", + "plc_operation", + "two_factor_code", + "channel_verification", + "passkey_recovery", + "legacy_login_alert", + "migration_verification" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + false, + false + ] + }, + "hash": "b0fca342e85dea89a06b4fee144cae4825dec587b1387f0fee401458aea2a2e5" +} diff --git a/.sqlx/query-c18d02b918c122ed2a4681971906f3e26e0ff0bd8733e036b2c3bc59401bc3a9.json b/.sqlx/query-c18d02b918c122ed2a4681971906f3e26e0ff0bd8733e036b2c3bc59401bc3a9.json new file mode 100644 index 0000000..fc72b15 --- /dev/null +++ b/.sqlx/query-c18d02b918c122ed2a4681971906f3e26e0ff0bd8733e036b2c3bc59401bc3a9.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT ic.code, ic.available_uses, ic.disabled, ic.for_account, ic.created_at,\n ic.created_by_user, u.did as created_by\n FROM invite_codes ic\n JOIN users u ON ic.created_by_user = u.id\n WHERE ic.created_by_user = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "code", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "available_uses", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "disabled", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "for_account", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "created_by_user", + "type_info": "Uuid" + }, + { + "ordinal": 6, + "name": "created_by", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false + ] + }, + "hash": "c18d02b918c122ed2a4681971906f3e26e0ff0bd8733e036b2c3bc59401bc3a9" +} diff --git a/.sqlx/query-cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee.json b/.sqlx/query-cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee.json new file mode 100644 index 0000000..6b53208 --- /dev/null +++ b/.sqlx/query-cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT password_reset_code FROM users WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "password_reset_code", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee" +} diff --git a/.sqlx/query-cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json b/.sqlx/query-cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json new file mode 100644 index 0000000..08e8e23 --- /dev/null +++ b/.sqlx/query-cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) as \"count!\" FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3" +} diff --git a/.sqlx/query-d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4.json b/.sqlx/query-d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4.json new file mode 100644 index 0000000..be751e8 --- /dev/null +++ b/.sqlx/query-d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE account_deletion_requests SET expires_at = NOW() - INTERVAL '1 hour' WHERE token = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4" +} diff --git a/.sqlx/query-d9affa3cc6ee8d007d58bc1a390aaf01b84e35656b15db2a74aeee9fedd58c9a.json b/.sqlx/query-d9affa3cc6ee8d007d58bc1a390aaf01b84e35656b15db2a74aeee9fedd58c9a.json new file mode 100644 index 0000000..6d38b64 --- /dev/null +++ b/.sqlx/query-d9affa3cc6ee8d007d58bc1a390aaf01b84e35656b15db2a74aeee9fedd58c9a.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT icu.code, u.did as used_by, icu.used_at\n FROM invite_code_uses icu\n JOIN users u ON icu.used_by_user = u.id\n WHERE icu.code = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "code", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "used_by", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "d9affa3cc6ee8d007d58bc1a390aaf01b84e35656b15db2a74aeee9fedd58c9a" +} diff --git a/.sqlx/query-e155d44cb2bd48ff141a27c51f34dfebeb628992a03f4bd6b10ade365ef8dc5e.json b/.sqlx/query-e155d44cb2bd48ff141a27c51f34dfebeb628992a03f4bd6b10ade365ef8dc5e.json deleted file mode 100644 index bca2451..0000000 --- a/.sqlx/query-e155d44cb2bd48ff141a27c51f34dfebeb628992a03f4bd6b10ade365ef8dc5e.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO record_blobs (repo_id, record_uri, blob_cid)\n VALUES ($1, $2, $3)\n ON CONFLICT (repo_id, record_uri, blob_cid) DO NOTHING\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "e155d44cb2bd48ff141a27c51f34dfebeb628992a03f4bd6b10ade365ef8dc5e" -} diff --git a/.sqlx/query-e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json b/.sqlx/query-e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json new file mode 100644 index 0000000..0576d91 --- /dev/null +++ b/.sqlx/query-e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM comms_queue WHERE user_id = $1 AND comms_type = 'password_reset'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3" +} diff --git a/.sqlx/query-e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9.json b/.sqlx/query-e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9.json new file mode 100644 index 0000000..edb2f4b --- /dev/null +++ b/.sqlx/query-e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT used_at FROM reserved_signing_keys WHERE public_key_did_key = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9" +} diff --git a/.sqlx/query-e80ef36b0354bb73568425b5e8787d45909ee764b80c0e9e8cd29871bd1e46d3.json b/.sqlx/query-e80ef36b0354bb73568425b5e8787d45909ee764b80c0e9e8cd29871bd1e46d3.json new file mode 100644 index 0000000..b6e00f0 --- /dev/null +++ b/.sqlx/query-e80ef36b0354bb73568425b5e8787d45909ee764b80c0e9e8cd29871bd1e46d3.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO record_blobs (repo_id, record_uri, blob_cid)\n SELECT $1, record_uri, blob_cid\n FROM UNNEST($2::text[], $3::text[]) AS t(record_uri, blob_cid)\n ON CONFLICT (repo_id, record_uri, blob_cid) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "TextArray", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "e80ef36b0354bb73568425b5e8787d45909ee764b80c0e9e8cd29871bd1e46d3" +} diff --git a/.sqlx/query-f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7.json b/.sqlx/query-f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7.json new file mode 100644 index 0000000..e8c8434 --- /dev/null +++ b/.sqlx/query-f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7" +} diff --git a/.sqlx/query-f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814.json b/.sqlx/query-f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814.json new file mode 100644 index 0000000..db8bde6 --- /dev/null +++ b/.sqlx/query-f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET is_admin = TRUE WHERE did = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814" +} diff --git a/.sqlx/query-f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382.json b/.sqlx/query-f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382.json new file mode 100644 index 0000000..32320a7 --- /dev/null +++ b/.sqlx/query-f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT password_reset_code, password_reset_code_expires_at FROM users WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "password_reset_code", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "password_reset_code_expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + true + ] + }, + "hash": "f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382" +} diff --git a/justfile b/justfile index f9260b9..2d1e8bb 100644 --- a/justfile +++ b/justfile @@ -19,8 +19,8 @@ fmt-check: cargo fmt -- --check lint: fmt-check clippy -test-all *args: - ./scripts/run-tests.sh {{args}} +test-unit: + SQLX_OFFLINE=true cargo test --test dpop_unit --test validation_edge_cases --test scope_edge_cases test-auth: ./scripts/run-tests.sh --test oauth --test oauth_lifecycle --test oauth_scopes --test oauth_security --test oauth_client_metadata --test jwt_security --test session_management --test change_password --test password_reset @@ -50,6 +50,7 @@ test-misc: ./scripts/run-tests.sh --test actor --test commit_signing --test image_processing --test lifecycle_social --test notifications --test server --test signing_key --test verify_live_commit test *args: + @just test-unit ./scripts/run-tests.sh {{args}} test-one name: diff --git a/migrations/20260102_composite_indexes.sql b/migrations/20260102_composite_indexes.sql new file mode 100644 index 0000000..5198d48 --- /dev/null +++ b/migrations/20260102_composite_indexes.sql @@ -0,0 +1,23 @@ +CREATE INDEX IF NOT EXISTS idx_session_tokens_did_created_at +ON session_tokens(did, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_oauth_token_did_expires_at +ON oauth_token(did, expires_at DESC); + +CREATE INDEX IF NOT EXISTS idx_oauth_token_did_created_at +ON oauth_token(did, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_session_tokens_did_refresh_expires +ON session_tokens(did, refresh_expires_at DESC); + +CREATE INDEX IF NOT EXISTS idx_app_passwords_user_created +ON app_passwords(user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_records_repo_collection_rkey +ON records(repo_id, collection, rkey); + +CREATE INDEX IF NOT EXISTS idx_passkeys_did_created +ON passkeys(did, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_backup_codes_did_unused +ON backup_codes(did) WHERE used_at IS NULL; diff --git a/src/api/admin/account/info.rs b/src/api/admin/account/info.rs index 4b7c477..55fd6e8 100644 --- a/src/api/admin/account/info.rs +++ b/src/api/admin/account/info.rs @@ -217,7 +217,10 @@ pub async fn get_account_infos( _auth: BearerAuthAdmin, RawQuery(raw_query): RawQuery, ) -> Response { - let dids = crate::util::parse_repeated_query_param(raw_query.as_deref(), "dids"); + let dids: Vec = crate::util::parse_repeated_query_param(raw_query.as_deref(), "dids") + .into_iter() + .filter(|d| !d.is_empty()) + .collect(); if dids.is_empty() { return ( StatusCode::BAD_REQUEST, @@ -225,41 +228,132 @@ pub async fn get_account_infos( ) .into_response(); } - let mut infos = Vec::new(); - for did in &dids { - if did.is_empty() { - continue; + let users = match sqlx::query!( + r#" + SELECT id, did, handle, email, created_at, invites_disabled, email_verified, deactivated_at + FROM users + WHERE did = ANY($1) + "#, + &dids + ) + .fetch_all(&state.db) + .await + { + Ok(rows) => rows, + Err(e) => { + error!("Failed to fetch account infos: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); } - let result = sqlx::query!( + }; + + let user_ids: Vec = users.iter().map(|u| u.id).collect(); + + let all_invite_codes = sqlx::query!( + r#" + SELECT ic.code, ic.available_uses, ic.disabled, ic.for_account, ic.created_at, + ic.created_by_user, u.did as created_by + FROM invite_codes ic + JOIN users u ON ic.created_by_user = u.id + WHERE ic.created_by_user = ANY($1) + "#, + &user_ids + ) + .fetch_all(&state.db) + .await + .unwrap_or_default(); + + let all_codes: Vec = all_invite_codes.iter().map(|c| c.code.clone()).collect(); + let all_invite_uses = if !all_codes.is_empty() { + sqlx::query!( r#" - SELECT id, did, handle, email, created_at, invites_disabled, email_verified, deactivated_at - FROM users - WHERE did = $1 + SELECT icu.code, u.did as used_by, icu.used_at + FROM invite_code_uses icu + JOIN users u ON icu.used_by_user = u.id + WHERE icu.code = ANY($1) "#, - did + &all_codes ) - .fetch_optional(&state.db) - .await; - if let Ok(Some(row)) = result { - let invited_by = get_invited_by(&state.db, row.id).await; - let invites = get_invites_for_user(&state.db, row.id).await; - infos.push(AccountInfo { - did: row.did, - handle: row.handle, - email: row.email, - indexed_at: row.created_at.to_rfc3339(), - invite_note: None, - invites_disabled: row.invites_disabled.unwrap_or(false), - email_confirmed_at: if row.email_verified { - Some(row.created_at.to_rfc3339()) - } else { - None - }, - deactivated_at: row.deactivated_at.map(|dt| dt.to_rfc3339()), - invited_by, - invites, + .fetch_all(&state.db) + .await + .unwrap_or_default() + } else { + Vec::new() + }; + + let invited_by_map: std::collections::HashMap = sqlx::query!( + r#" + SELECT icu.used_by_user, icu.code + FROM invite_code_uses icu + WHERE icu.used_by_user = ANY($1) + "#, + &user_ids + ) + .fetch_all(&state.db) + .await + .unwrap_or_default() + .into_iter() + .map(|r| (r.used_by_user, r.code)) + .collect(); + + let mut uses_by_code: std::collections::HashMap> = + std::collections::HashMap::new(); + for u in all_invite_uses { + uses_by_code + .entry(u.code.clone()) + .or_default() + .push(InviteCodeUseInfo { + used_by: u.used_by, + used_at: u.used_at.to_rfc3339(), }); - } + } + + let mut codes_by_user: std::collections::HashMap> = + std::collections::HashMap::new(); + let mut code_info_map: std::collections::HashMap = + std::collections::HashMap::new(); + for ic in all_invite_codes { + let info = InviteCodeInfo { + code: ic.code.clone(), + available: ic.available_uses, + disabled: ic.disabled.unwrap_or(false), + for_account: ic.for_account, + created_by: ic.created_by, + created_at: ic.created_at.to_rfc3339(), + uses: uses_by_code.get(&ic.code).cloned().unwrap_or_default(), + }; + code_info_map.insert(ic.code.clone(), info.clone()); + codes_by_user + .entry(ic.created_by_user) + .or_default() + .push(info); + } + + let mut infos = Vec::with_capacity(users.len()); + for row in users { + let invited_by = invited_by_map + .get(&row.id) + .and_then(|code| code_info_map.get(code).cloned()); + let invites = codes_by_user.get(&row.id).cloned(); + infos.push(AccountInfo { + did: row.did, + handle: row.handle, + email: row.email, + indexed_at: row.created_at.to_rfc3339(), + invite_note: None, + invites_disabled: row.invites_disabled.unwrap_or(false), + email_confirmed_at: if row.email_verified { + Some(row.created_at.to_rfc3339()) + } else { + None + }, + deactivated_at: row.deactivated_at.map(|dt| dt.to_rfc3339()), + invited_by, + invites, + }); } (StatusCode::OK, Json(GetAccountInfosOutput { infos })).into_response() } diff --git a/src/api/delegation.rs b/src/api/delegation.rs index 85b26f6..7d795f8 100644 --- a/src/api/delegation.rs +++ b/src/api/delegation.rs @@ -726,7 +726,7 @@ pub async fn create_delegated_account( } }; - let plc_client = crate::plc::PlcClient::new(None); + let plc_client = crate::plc::PlcClient::with_cache(None, Some(state.cache.clone())); if let Err(e) = plc_client .send_operation(&genesis_result.did, &genesis_result.signed_operation) .await diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs index 469f5f4..05dfce6 100644 --- a/src/api/identity/account.rs +++ b/src/api/identity/account.rs @@ -451,7 +451,7 @@ pub async fn create_account( .into_response(); } }; - let plc_client = PlcClient::new(None); + let plc_client = PlcClient::with_cache(None, Some(state.cache.clone())); if let Err(e) = plc_client .send_operation(&genesis_result.did, &genesis_result.signed_operation) .await @@ -488,7 +488,7 @@ pub async fn create_account( .into_response(); } }; - let plc_client = PlcClient::new(None); + let plc_client = PlcClient::with_cache(None, Some(state.cache.clone())); if let Err(e) = plc_client .send_operation(&genesis_result.did, &genesis_result.signed_operation) .await @@ -745,17 +745,27 @@ pub async fn create_account( .into_response(); } - let password_hash = match hash(&input.password, DEFAULT_COST) { - Ok(h) => h, - Err(e) => { - error!("Error hashing password: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; + let password_clone = input.password.clone(); + let password_hash = + match tokio::task::spawn_blocking(move || hash(&password_clone, DEFAULT_COST)).await { + Ok(Ok(h)) => h, + Ok(Err(e)) => { + error!("Error hashing password: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + Err(e) => { + error!("Failed to spawn blocking task: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; let is_first_user = sqlx::query_scalar!("SELECT COUNT(*) as count FROM users") .fetch_one(&mut *tx) .await diff --git a/src/api/identity/did.rs b/src/api/identity/did.rs index c4ceef7..e7a81b7 100644 --- a/src/api/identity/did.rs +++ b/src/api/identity/did.rs @@ -10,7 +10,6 @@ use axum::{ use base64::Engine; use k256::SecretKey; use k256::elliptic_curve::sec1::ToEncodedPoint; -use reqwest; use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::{error, warn}; @@ -504,10 +503,7 @@ pub async fn verify_did_web( let path = parts[3..].join("/"); format!("{}://{}/{}/did.json", scheme, domain, path) }; - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(5)) - .build() - .map_err(|e| format!("Failed to create client: {}", e))?; + let client = crate::api::proxy_client::did_resolution_client(); let resp = client .get(&url) .send() @@ -926,7 +922,7 @@ pub async fn update_plc_handle( }; let key_bytes = crate::config::decrypt_key(&user_row.key_bytes, user_row.encryption_version)?; let signing_key = k256::ecdsa::SigningKey::from_slice(&key_bytes)?; - let plc_client = crate::plc::PlcClient::new(None); + let plc_client = crate::plc::PlcClient::with_cache(None, Some(state.cache.clone())); let last_op = plc_client.get_last_op(did).await?; let new_also_known_as = vec![format!("at://{}", new_handle)]; let update_op = diff --git a/src/api/identity/plc/sign.rs b/src/api/identity/plc/sign.rs index 46d2772..4e48858 100644 --- a/src/api/identity/plc/sign.rs +++ b/src/api/identity/plc/sign.rs @@ -174,7 +174,7 @@ pub async fn sign_plc_operation( .into_response(); } }; - let plc_client = PlcClient::new(None); + let plc_client = PlcClient::with_cache(None, Some(state.cache.clone())); let did_clone = did.clone(); let result: Result> = with_circuit_breaker(&state.circuit_breakers.plc_directory, || async { diff --git a/src/api/identity/plc/submit.rs b/src/api/identity/plc/submit.rs index 0e5e901..db6f607 100644 --- a/src/api/identity/plc/submit.rs +++ b/src/api/identity/plc/submit.rs @@ -184,7 +184,7 @@ pub async fn submit_plc_operation( .into_response(); } } - let plc_client = PlcClient::new(None); + let plc_client = PlcClient::with_cache(None, Some(state.cache.clone())); let operation_clone = input.operation.clone(); let did_clone = did.clone(); let result: Result<(), CircuitBreakerError> = diff --git a/src/api/proxy_client.rs b/src/api/proxy_client.rs index 6f2965b..a364163 100644 --- a/src/api/proxy_client.rs +++ b/src/api/proxy_client.rs @@ -10,6 +10,8 @@ pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); pub const MAX_RESPONSE_SIZE: u64 = 10 * 1024 * 1024; static PROXY_CLIENT: OnceLock = OnceLock::new(); +static DID_RESOLUTION_CLIENT: OnceLock = OnceLock::new(); +static HANDLE_RESOLUTION_CLIENT: OnceLock = OnceLock::new(); pub fn proxy_client() -> &'static Client { PROXY_CLIENT.get_or_init(|| { @@ -26,6 +28,35 @@ pub fn proxy_client() -> &'static Client { }) } +pub fn did_resolution_client() -> &'static Client { + DID_RESOLUTION_CLIENT.get_or_init(|| { + ClientBuilder::new() + .timeout(Duration::from_secs(5)) + .connect_timeout(DEFAULT_CONNECT_TIMEOUT) + .pool_max_idle_per_host(10) + .pool_idle_timeout(Duration::from_secs(90)) + .build() + .expect( + "Failed to build DID resolution client - this indicates a TLS or system configuration issue", + ) + }) +} + +pub fn handle_resolution_client() -> &'static Client { + HANDLE_RESOLUTION_CLIENT.get_or_init(|| { + ClientBuilder::new() + .timeout(Duration::from_secs(10)) + .connect_timeout(DEFAULT_CONNECT_TIMEOUT) + .pool_max_idle_per_host(10) + .pool_idle_timeout(Duration::from_secs(90)) + .redirect(reqwest::redirect::Policy::limited(5)) + .build() + .expect( + "Failed to build handle resolution client - this indicates a TLS or system configuration issue", + ) + }) +} + pub fn is_ssrf_safe(url: &str) -> Result<(), SsrfError> { let parsed = Url::parse(url).map_err(|_| SsrfError::InvalidUrl)?; let scheme = parsed.scheme(); diff --git a/src/api/repo/blob.rs b/src/api/repo/blob.rs index 2b663b0..b0cbe0a 100644 --- a/src/api/repo/blob.rs +++ b/src/api/repo/blob.rs @@ -2,24 +2,26 @@ use crate::auth::{ServiceTokenVerifier, is_service_token}; use crate::delegation::{self, DelegationActionType}; use crate::state::AppState; use crate::util::get_max_blob_size; -use axum::body::Bytes; +use axum::body::Body; use axum::{ Json, extract::{Query, State}, http::StatusCode, response::{IntoResponse, Response}, }; +use bytes::Bytes; use cid::Cid; +use futures::StreamExt; use multihash::Multihash; use serde::{Deserialize, Serialize}; use serde_json::json; -use sha2::{Digest, Sha256}; -use tracing::{debug, error}; +use std::pin::Pin; +use tracing::{debug, error, info}; pub async fn upload_blob( State(state): State, headers: axum::http::HeaderMap, - body: Bytes, + body: Body, ) -> Response { let token = match crate::auth::extract_bearer_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), @@ -106,39 +108,12 @@ pub async fn upload_blob( .into_response(); } - let max_size = get_max_blob_size(); - - if body.len() > max_size { - return ( - StatusCode::PAYLOAD_TOO_LARGE, - Json(json!({"error": "BlobTooLarge", "message": format!("Blob size {} exceeds maximum of {} bytes", body.len(), max_size)})), - ) - .into_response(); - } let mime_type = headers .get("content-type") .and_then(|h| h.to_str().ok()) .unwrap_or("application/octet-stream") .to_string(); - let size = body.len() as i64; - let data = body.to_vec(); - let mut hasher = Sha256::new(); - hasher.update(&data); - let hash = hasher.finalize(); - let multihash = match Multihash::wrap(0x12, &hash) { - Ok(mh) => mh, - Err(e) => { - error!("Failed to create multihash for blob: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to hash blob"})), - ) - .into_response(); - } - }; - let cid = Cid::new_v1(0x55, multihash); - let cid_str = cid.to_string(); - let storage_key = format!("blobs/{}", cid_str); + let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", did) .fetch_optional(&state.db) .await; @@ -152,9 +127,65 @@ pub async fn upload_blob( .into_response(); } }; + + let temp_key = format!("temp/{}", uuid::Uuid::new_v4()); + let max_size = get_max_blob_size() as u64; + + let body_stream = body.into_data_stream(); + let mapped_stream = + body_stream.map(|result| result.map_err(|e| std::io::Error::other(e.to_string()))); + let pinned_stream: Pin> + Send>> = + Box::pin(mapped_stream); + + info!("Starting streaming blob upload to temp key: {}", temp_key); + + let upload_result = match state.blob_store.put_stream(&temp_key, pinned_stream).await { + Ok(result) => result, + Err(e) => { + error!("Failed to stream blob to storage: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to store blob"})), + ) + .into_response(); + } + }; + + let size = upload_result.size; + if size > max_size { + let _ = state.blob_store.delete(&temp_key).await; + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json(json!({"error": "BlobTooLarge", "message": format!("Blob size {} exceeds maximum of {} bytes", size, max_size)})), + ) + .into_response(); + } + + let multihash = match Multihash::wrap(0x12, &upload_result.sha256_hash) { + Ok(mh) => mh, + Err(e) => { + let _ = state.blob_store.delete(&temp_key).await; + error!("Failed to create multihash for blob: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to hash blob"})), + ) + .into_response(); + } + }; + let cid = Cid::new_v1(0x55, multihash); + let cid_str = cid.to_string(); + let storage_key = format!("blobs/{}", cid_str); + + info!( + "Blob upload complete: size={}, cid={}, copying to final location", + size, cid_str + ); + let mut tx = match state.db.begin().await { Ok(tx) => tx, Err(e) => { + let _ = state.blob_store.delete(&temp_key).await; error!("Failed to begin transaction: {:?}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, @@ -163,20 +194,23 @@ pub async fn upload_blob( .into_response(); } }; + let insert = sqlx::query!( "INSERT INTO blobs (cid, mime_type, size_bytes, created_by_user, storage_key) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (cid) DO NOTHING RETURNING cid", cid_str, mime_type, - size, + size as i64, user_id, storage_key ) .fetch_optional(&mut *tx) .await; + let was_inserted = match insert { Ok(Some(_)) => true, Ok(None) => false, Err(e) => { + let _ = state.blob_store.delete(&temp_key).await; error!("Failed to insert blob record: {:?}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, @@ -185,19 +219,19 @@ pub async fn upload_blob( .into_response(); } }; - if was_inserted - && let Err(e) = state - .blob_store - .put_bytes(&storage_key, bytes::Bytes::from(data)) - .await - { - error!("Failed to upload blob to storage: {:?}", e); + + if was_inserted && let Err(e) = state.blob_store.copy(&temp_key, &storage_key).await { + let _ = state.blob_store.delete(&temp_key).await; + error!("Failed to copy blob to final location: {:?}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to store blob"})), ) .into_response(); } + + let _ = state.blob_store.delete(&temp_key).await; + if let Err(e) = tx.commit().await { error!("Failed to commit blob transaction: {:?}", e); if was_inserted && let Err(cleanup_err) = state.blob_store.delete(&storage_key).await { diff --git a/src/api/repo/import.rs b/src/api/repo/import.rs index 37695d2..e22f326 100644 --- a/src/api/repo/import.rs +++ b/src/api/repo/import.rs @@ -16,8 +16,8 @@ use k256::ecdsa::SigningKey; use serde_json::json; use tracing::{debug, error, info, warn}; -const DEFAULT_MAX_IMPORT_SIZE: usize = 100 * 1024 * 1024; -const DEFAULT_MAX_BLOCKS: usize = 50000; +const DEFAULT_MAX_IMPORT_SIZE: usize = 1024 * 1024 * 1024; +const DEFAULT_MAX_BLOCKS: usize = 500000; pub async fn import_repo( State(state): State, diff --git a/src/api/server/account_status.rs b/src/api/server/account_status.rs index 7201f04..9979455 100644 --- a/src/api/server/account_status.rs +++ b/src/api/server/account_status.rs @@ -1,4 +1,5 @@ use crate::api::ApiError; +use crate::cache::Cache; use crate::plc::PlcClient; use crate::state::AppState; use axum::{ @@ -16,6 +17,7 @@ use k256::ecdsa::SigningKey; use serde::{Deserialize, Serialize}; use serde_json::json; use std::str::FromStr; +use std::sync::Arc; use tracing::{error, info, warn}; use uuid::Uuid; @@ -140,7 +142,7 @@ pub async fn check_account_status( .await .unwrap_or(Some(0)) .unwrap_or(0); - let valid_did = is_valid_did_for_service(&state.db, &did).await; + let valid_did = is_valid_did_for_service(&state.db, &state.cache, &did).await; ( StatusCode::OK, Json(CheckAccountStatusOutput { @@ -158,14 +160,15 @@ pub async fn check_account_status( .into_response() } -async fn is_valid_did_for_service(db: &sqlx::PgPool, did: &str) -> bool { - assert_valid_did_document_for_service(db, did, false) +async fn is_valid_did_for_service(db: &sqlx::PgPool, cache: &Arc, did: &str) -> bool { + assert_valid_did_document_for_service(db, cache, did, false) .await .is_ok() } async fn assert_valid_did_document_for_service( db: &sqlx::PgPool, + cache: &Arc, did: &str, with_retry: bool, ) -> Result<(), (StatusCode, Json)> { @@ -173,7 +176,7 @@ async fn assert_valid_did_document_for_service( let expected_endpoint = format!("https://{}", hostname); if did.starts_with("did:plc:") { - let plc_client = PlcClient::new(None); + let plc_client = PlcClient::with_cache(None, Some(cache.clone())); let max_attempts = if with_retry { 5 } else { 1 }; let mut last_error = None; @@ -308,7 +311,7 @@ async fn assert_valid_did_document_for_service( } } } else if let Some(host_and_path) = did.strip_prefix("did:web:") { - let client = reqwest::Client::new(); + let client = crate::api::proxy_client::did_resolution_client(); let decoded = host_and_path.replace("%3A", ":"); let parts: Vec<&str> = decoded.split(':').collect(); let (host, path_parts) = if parts.len() > 1 && parts[1].chars().all(|c| c.is_ascii_digit()) @@ -438,7 +441,8 @@ pub async fn activate_account( did ); let did_validation_start = std::time::Instant::now(); - if let Err((status, json)) = assert_valid_did_document_for_service(&state.db, &did, true).await + if let Err((status, json)) = + assert_valid_did_document_for_service(&state.db, &state.cache, &did, true).await { info!( "[MIGRATION] activateAccount: DID document validation FAILED for {} (took {:?})", diff --git a/src/api/server/app_password.rs b/src/api/server/app_password.rs index 0772d9c..07ee771 100644 --- a/src/api/server/app_password.rs +++ b/src/api/server/app_password.rs @@ -158,12 +158,21 @@ pub async fn create_app_password( }) .collect::>() .join("-"); - let password_hash = match bcrypt::hash(&password, bcrypt::DEFAULT_COST) { - Ok(h) => h, - Err(e) => { + let password_clone = password.clone(); + let password_hash = match tokio::task::spawn_blocking(move || { + bcrypt::hash(&password_clone, bcrypt::DEFAULT_COST) + }) + .await + { + Ok(Ok(h)) => h, + Ok(Err(e)) => { error!("Failed to hash password: {:?}", e); return ApiError::InternalError.into_response(); } + Err(e) => { + error!("Failed to spawn blocking task: {:?}", e); + return ApiError::InternalError.into_response(); + } }; let privileged = input.privileged.unwrap_or(false); let created_at = chrono::Utc::now(); diff --git a/src/api/server/passkey_account.rs b/src/api/server/passkey_account.rs index ab5e37d..5eec41a 100644 --- a/src/api/server/passkey_account.rs +++ b/src/api/server/passkey_account.rs @@ -436,7 +436,7 @@ pub async fn create_passkey_account( } }; - let plc_client = crate::plc::PlcClient::new(None); + let plc_client = crate::plc::PlcClient::with_cache(None, Some(state.cache.clone())); if let Err(e) = plc_client .send_operation(&genesis_result.did, &genesis_result.signed_operation) .await diff --git a/src/api/server/password.rs b/src/api/server/password.rs index 2e0d0ae..ce9ed70 100644 --- a/src/api/server/password.rs +++ b/src/api/server/password.rs @@ -226,17 +226,27 @@ pub async fn reset_password( ) .into_response(); } - let password_hash = match hash(password, DEFAULT_COST) { - Ok(h) => h, - Err(e) => { - error!("Failed to hash password: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; + let password_clone = password.to_string(); + let password_hash = + match tokio::task::spawn_blocking(move || hash(password_clone, DEFAULT_COST)).await { + Ok(Ok(h)) => h, + Ok(Err(e)) => { + error!("Failed to hash password: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + Err(e) => { + error!("Failed to spawn blocking task: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; let mut tx = match state.db.begin().await { Ok(tx) => tx, Err(e) => { @@ -409,17 +419,27 @@ pub async fn change_password( ) .into_response(); } - let new_hash = match hash(new_password, DEFAULT_COST) { - Ok(h) => h, - Err(e) => { - error!("Failed to hash password: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; + let new_password_clone = new_password.to_string(); + let new_hash = + match tokio::task::spawn_blocking(move || hash(new_password_clone, DEFAULT_COST)).await { + Ok(Ok(h)) => h, + Ok(Err(e)) => { + error!("Failed to hash password: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + Err(e) => { + error!("Failed to spawn blocking task: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; if let Err(e) = sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2") .bind(&new_hash) .bind(user_id) diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 7570e86..a41ce62 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -35,6 +35,14 @@ pub use verify::{ const KEY_CACHE_TTL_SECS: u64 = 300; const SESSION_CACHE_TTL_SECS: u64 = 60; +const USER_STATUS_CACHE_TTL_SECS: u64 = 60; + +#[derive(Serialize, Deserialize)] +struct CachedUserStatus { + deactivated: bool, + takendown: bool, + is_admin: bool, +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TokenValidationError { @@ -149,23 +157,67 @@ async fn validate_bearer_token_with_options_internal( let (decrypted_key, deactivated_at, takedown_ref, is_admin) = if let Some(key) = cached_key { - let user_status = sqlx::query!( - "SELECT deactivated_at, takedown_ref, is_admin FROM users WHERE did = $1", - did - ) - .fetch_optional(db) - .await - .ok() - .flatten(); + let status_cache_key = format!("auth:status:{}", did); + let cached_status: Option = if let Some(c) = cache { + c.get(&status_cache_key) + .await + .and_then(|s| serde_json::from_str(&s).ok()) + } else { + None + }; - match user_status { - Some(status) => ( + if let Some(status) = cached_status { + ( Some(key), - status.deactivated_at, - status.takedown_ref, + if status.deactivated { + Some(chrono::Utc::now()) + } else { + None + }, + if status.takendown { + Some("takendown".to_string()) + } else { + None + }, status.is_admin, - ), - None => (None, None, None, false), + ) + } else { + let user_status = sqlx::query!( + "SELECT deactivated_at, takedown_ref, is_admin FROM users WHERE did = $1", + did + ) + .fetch_optional(db) + .await + .ok() + .flatten(); + + match user_status { + Some(status) => { + if let Some(c) = cache { + let cached = CachedUserStatus { + deactivated: status.deactivated_at.is_some(), + takendown: status.takedown_ref.is_some(), + is_admin: status.is_admin, + }; + if let Ok(json) = serde_json::to_string(&cached) { + let _ = c + .set( + &status_cache_key, + &json, + Duration::from_secs(USER_STATUS_CACHE_TTL_SECS), + ) + .await; + } + } + ( + Some(key), + status.deactivated_at, + status.takedown_ref, + status.is_admin, + ) + } + None => (None, None, None, false), + } } } else if let Some(user) = sqlx::query!( "SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref, u.is_admin @@ -190,6 +242,22 @@ async fn validate_bearer_token_with_options_internal( Duration::from_secs(KEY_CACHE_TTL_SECS), ) .await; + + let status_cache_key = format!("auth:status:{}", did); + let cached = CachedUserStatus { + deactivated: user.deactivated_at.is_some(), + takendown: user.takedown_ref.is_some(), + is_admin: user.is_admin, + }; + if let Ok(json) = serde_json::to_string(&cached) { + let _ = c + .set( + &status_cache_key, + &json, + Duration::from_secs(USER_STATUS_CACHE_TTL_SECS), + ) + .await; + } } ( @@ -328,7 +396,9 @@ async fn validate_bearer_token_with_options_internal( pub async fn invalidate_auth_cache(cache: &Arc, did: &str) { let key_cache_key = format!("auth:key:{}", did); + let status_cache_key = format!("auth:status:{}", did); let _ = cache.delete(&key_cache_key).await; + let _ = cache.delete(&status_cache_key).await; } pub async fn validate_token_with_dpop( diff --git a/src/auth/service.rs b/src/auth/service.rs index b5fd571..5b885c2 100644 --- a/src/auth/service.rs +++ b/src/auth/service.rs @@ -85,6 +85,8 @@ impl ServiceTokenVerifier { let client = Client::builder() .timeout(Duration::from_secs(10)) .connect_timeout(Duration::from_secs(5)) + .pool_max_idle_per_host(10) + .pool_idle_timeout(Duration::from_secs(90)) .build() .unwrap_or_else(|_| Client::new()); diff --git a/src/crawlers.rs b/src/crawlers.rs index 9e664f9..3616e8a 100644 --- a/src/crawlers.rs +++ b/src/crawlers.rs @@ -24,6 +24,9 @@ impl Crawlers { crawler_urls, http_client: Client::builder() .timeout(Duration::from_secs(30)) + .connect_timeout(Duration::from_secs(5)) + .pool_max_idle_per_host(5) + .pool_idle_timeout(Duration::from_secs(90)) .build() .unwrap_or_default(), last_notified: AtomicU64::new(0), diff --git a/src/handle/mod.rs b/src/handle/mod.rs index 893b4e3..4e0bd53 100644 --- a/src/handle/mod.rs +++ b/src/handle/mod.rs @@ -2,8 +2,6 @@ pub mod reserved; use hickory_resolver::TokioAsyncResolver; use hickory_resolver::config::{ResolverConfig, ResolverOpts}; -use reqwest::Client; -use std::time::Duration; use thiserror::Error; #[derive(Error, Debug)] @@ -43,11 +41,7 @@ pub async fn resolve_handle_dns(handle: &str) -> Result Result { let url = format!("https://{}/.well-known/atproto-did", handle); - let client = Client::builder() - .timeout(Duration::from_secs(10)) - .redirect(reqwest::redirect::Policy::limited(5)) - .build() - .map_err(|e| HandleResolutionError::HttpError(e.to_string()))?; + let client = crate::api::proxy_client::handle_resolution_client(); let response = client .get(&url) .header("Accept", "text/plain") diff --git a/src/oauth/client.rs b/src/oauth/client.rs index 874f1ed..1f0cf26 100644 --- a/src/oauth/client.rs +++ b/src/oauth/client.rs @@ -80,6 +80,8 @@ impl ClientMetadataCache { http_client: Client::builder() .timeout(std::time::Duration::from_secs(30)) .connect_timeout(std::time::Duration::from_secs(10)) + .pool_max_idle_per_host(10) + .pool_idle_timeout(std::time::Duration::from_secs(90)) .build() .unwrap_or_else(|_| Client::new()), cache_ttl_secs, diff --git a/src/plc/mod.rs b/src/plc/mod.rs index 556ffd4..389dd5e 100644 --- a/src/plc/mod.rs +++ b/src/plc/mod.rs @@ -1,3 +1,4 @@ +use crate::cache::Cache; use base32::Alphabet; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use k256::ecdsa::{Signature, SigningKey, signature::Signer}; @@ -6,6 +7,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::collections::HashMap; +use std::sync::Arc; use std::time::Duration; use thiserror::Error; @@ -77,13 +79,20 @@ impl PlcOpOrTombstone { } } +const PLC_CACHE_TTL_SECS: u64 = 300; + pub struct PlcClient { base_url: String, client: Client, + cache: Option>, } impl PlcClient { pub fn new(base_url: Option) -> Self { + Self::with_cache(base_url, None) + } + + pub fn with_cache(base_url: Option, cache: Option>) -> Self { let base_url = base_url.unwrap_or_else(|| { std::env::var("PLC_DIRECTORY_URL") .unwrap_or_else(|_| "https://plc.directory".to_string()) @@ -100,9 +109,14 @@ impl PlcClient { .timeout(Duration::from_secs(timeout_secs)) .connect_timeout(Duration::from_secs(connect_timeout_secs)) .pool_max_idle_per_host(5) + .pool_idle_timeout(Duration::from_secs(90)) .build() .unwrap_or_else(|_| Client::new()); - Self { base_url, client } + Self { + base_url, + client, + cache, + } } fn encode_did(did: &str) -> String { @@ -110,6 +124,13 @@ impl PlcClient { } pub async fn get_document(&self, did: &str) -> Result { + let cache_key = format!("plc:doc:{}", did); + if let Some(ref cache) = self.cache + && let Some(cached) = cache.get(&cache_key).await + && let Ok(value) = serde_json::from_str(&cached) + { + return Ok(value); + } let url = format!("{}/{}", self.base_url, Self::encode_did(did)); let response = self.client.get(&url).send().await?; if response.status() == reqwest::StatusCode::NOT_FOUND { @@ -123,13 +144,32 @@ impl PlcClient { status, body ))); } - response + let value: Value = response .json() .await - .map_err(|e| PlcError::InvalidResponse(e.to_string())) + .map_err(|e| PlcError::InvalidResponse(e.to_string()))?; + if let Some(ref cache) = self.cache + && let Ok(json_str) = serde_json::to_string(&value) + { + let _ = cache + .set( + &cache_key, + &json_str, + Duration::from_secs(PLC_CACHE_TTL_SECS), + ) + .await; + } + Ok(value) } pub async fn get_document_data(&self, did: &str) -> Result { + let cache_key = format!("plc:data:{}", did); + if let Some(ref cache) = self.cache + && let Some(cached) = cache.get(&cache_key).await + && let Ok(value) = serde_json::from_str(&cached) + { + return Ok(value); + } let url = format!("{}/{}/data", self.base_url, Self::encode_did(did)); let response = self.client.get(&url).send().await?; if response.status() == reqwest::StatusCode::NOT_FOUND { @@ -143,10 +183,22 @@ impl PlcClient { status, body ))); } - response + let value: Value = response .json() .await - .map_err(|e| PlcError::InvalidResponse(e.to_string())) + .map_err(|e| PlcError::InvalidResponse(e.to_string()))?; + if let Some(ref cache) = self.cache + && let Ok(json_str) = serde_json::to_string(&value) + { + let _ = cache + .set( + &cache_key, + &json_str, + Duration::from_secs(PLC_CACHE_TTL_SECS), + ) + .await; + } + Ok(value) } pub async fn get_last_op(&self, did: &str) -> Result { diff --git a/src/scheduled.rs b/src/scheduled.rs index 69ddd68..8627738 100644 --- a/src/scheduled.rs +++ b/src/scheduled.rs @@ -343,7 +343,9 @@ pub async fn backfill_record_blobs(db: &PgPool, block_store: PostgresBlockStore) } }; - let mut blob_refs_found = 0; + let mut batch_record_uris: Vec = Vec::new(); + let mut batch_blob_cids: Vec = Vec::new(); + for record in records { let record_cid = match Cid::from_str(&record.record_cid) { Ok(c) => c, @@ -363,33 +365,36 @@ pub async fn backfill_record_blobs(db: &PgPool, block_store: PostgresBlockStore) let blob_refs = crate::sync::import::find_blob_refs_ipld(&record_ipld, 0); for blob_ref in blob_refs { let record_uri = format!("at://{}/{}/{}", user.did, record.collection, record.rkey); - if let Err(e) = sqlx::query!( - r#" - INSERT INTO record_blobs (repo_id, record_uri, blob_cid) - VALUES ($1, $2, $3) - ON CONFLICT (repo_id, record_uri, blob_cid) DO NOTHING - "#, - user.user_id, - record_uri, - blob_ref.cid - ) - .execute(db) - .await - { - warn!(error = %e, "Failed to insert record_blob during backfill"); - } else { - blob_refs_found += 1; - } + batch_record_uris.push(record_uri); + batch_blob_cids.push(blob_ref.cid); } } - if blob_refs_found > 0 { - info!( - user_id = %user.user_id, - did = %user.did, - blob_refs = blob_refs_found, - "Backfilled record_blobs" - ); + let blob_refs_found = batch_record_uris.len(); + if !batch_record_uris.is_empty() { + if let Err(e) = sqlx::query!( + r#" + INSERT INTO record_blobs (repo_id, record_uri, blob_cid) + SELECT $1, record_uri, blob_cid + FROM UNNEST($2::text[], $3::text[]) AS t(record_uri, blob_cid) + ON CONFLICT (repo_id, record_uri, blob_cid) DO NOTHING + "#, + user.user_id, + &batch_record_uris, + &batch_blob_cids + ) + .execute(db) + .await + { + warn!(error = %e, "Failed to batch insert record_blobs during backfill"); + } else { + info!( + user_id = %user.user_id, + did = %user.did, + blob_refs = blob_refs_found, + "Backfilled record_blobs" + ); + } } success += 1; } diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 422f91b..62a0d0b 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -3,9 +3,16 @@ use aws_config::BehaviorVersion; use aws_config::meta::region::RegionProviderChain; use aws_sdk_s3::Client; use aws_sdk_s3::primitives::ByteStream; +use aws_sdk_s3::types::CompletedMultipartUpload; +use aws_sdk_s3::types::CompletedPart; use bytes::Bytes; +use futures::Stream; +use sha2::{Digest, Sha256}; +use std::pin::Pin; use thiserror::Error; +const MIN_PART_SIZE: usize = 5 * 1024 * 1024; + #[derive(Error, Debug)] pub enum StorageError { #[error("IO error: {0}")] @@ -16,6 +23,11 @@ pub enum StorageError { Other(String), } +pub struct StreamUploadResult { + pub sha256_hash: [u8; 32], + pub size: u64, +} + #[async_trait] pub trait BlobStorage: Send + Sync { async fn put(&self, key: &str, data: &[u8]) -> Result<(), StorageError>; @@ -23,6 +35,12 @@ pub trait BlobStorage: Send + Sync { async fn get(&self, key: &str) -> Result, StorageError>; async fn get_bytes(&self, key: &str) -> Result; async fn delete(&self, key: &str) -> Result<(), StorageError>; + async fn put_stream( + &self, + key: &str, + stream: Pin> + Send>>, + ) -> Result; + async fn copy(&self, src_key: &str, dst_key: &str) -> Result<(), StorageError>; } pub struct S3BlobStorage { @@ -233,4 +251,170 @@ impl BlobStorage for S3BlobStorage { result?; Ok(()) } + + async fn put_stream( + &self, + key: &str, + mut stream: Pin> + Send>>, + ) -> Result { + use futures::StreamExt; + + let create_resp = self + .client + .create_multipart_upload() + .bucket(&self.bucket) + .key(key) + .send() + .await + .map_err(|e| StorageError::S3(format!("Failed to create multipart upload: {}", e)))?; + + let upload_id = create_resp + .upload_id() + .ok_or_else(|| StorageError::S3("No upload ID returned".to_string()))? + .to_string(); + + let mut hasher = Sha256::new(); + let mut total_size: u64 = 0; + let mut part_number = 1; + let mut completed_parts: Vec = Vec::new(); + let mut buffer = Vec::with_capacity(MIN_PART_SIZE); + + let upload_part = |client: &Client, + bucket: &str, + key: &str, + upload_id: &str, + part_num: i32, + data: Vec| + -> std::pin::Pin< + Box> + Send>, + > { + let client = client.clone(); + let bucket = bucket.to_string(); + let key = key.to_string(); + let upload_id = upload_id.to_string(); + Box::pin(async move { + let resp = client + .upload_part() + .bucket(&bucket) + .key(&key) + .upload_id(&upload_id) + .part_number(part_num) + .body(ByteStream::from(data)) + .send() + .await + .map_err(|e| StorageError::S3(format!("Failed to upload part: {}", e)))?; + + let etag = resp + .e_tag() + .ok_or_else(|| StorageError::S3("No ETag returned for part".to_string()))? + .to_string(); + + Ok(CompletedPart::builder() + .part_number(part_num) + .e_tag(etag) + .build()) + }) + }; + + loop { + match stream.next().await { + Some(Ok(chunk)) => { + hasher.update(&chunk); + total_size += chunk.len() as u64; + buffer.extend_from_slice(&chunk); + + if buffer.len() >= MIN_PART_SIZE { + let part_data = + std::mem::replace(&mut buffer, Vec::with_capacity(MIN_PART_SIZE)); + let part = upload_part( + &self.client, + &self.bucket, + key, + &upload_id, + part_number, + part_data, + ) + .await?; + completed_parts.push(part); + part_number += 1; + } + } + Some(Err(e)) => { + let _ = self + .client + .abort_multipart_upload() + .bucket(&self.bucket) + .key(key) + .upload_id(&upload_id) + .send() + .await; + return Err(StorageError::Io(e)); + } + None => break, + } + } + + if !buffer.is_empty() { + let part = upload_part( + &self.client, + &self.bucket, + key, + &upload_id, + part_number, + buffer, + ) + .await?; + completed_parts.push(part); + } + + if completed_parts.is_empty() { + let _ = self + .client + .abort_multipart_upload() + .bucket(&self.bucket) + .key(key) + .upload_id(&upload_id) + .send() + .await; + return Err(StorageError::Other("Empty upload".to_string())); + } + + let completed_upload = CompletedMultipartUpload::builder() + .set_parts(Some(completed_parts)) + .build(); + + self.client + .complete_multipart_upload() + .bucket(&self.bucket) + .key(key) + .upload_id(&upload_id) + .multipart_upload(completed_upload) + .send() + .await + .map_err(|e| StorageError::S3(format!("Failed to complete multipart upload: {}", e)))?; + + crate::metrics::record_s3_operation("put_stream", "success"); + + let hash: [u8; 32] = hasher.finalize().into(); + Ok(StreamUploadResult { + sha256_hash: hash, + size: total_size, + }) + } + + async fn copy(&self, src_key: &str, dst_key: &str) -> Result<(), StorageError> { + let copy_source = format!("{}/{}", self.bucket, src_key); + + self.client + .copy_object() + .bucket(&self.bucket) + .copy_source(©_source) + .key(dst_key) + .send() + .await + .map_err(|e| StorageError::S3(format!("Failed to copy object: {}", e)))?; + + crate::metrics::record_s3_operation("copy", "success"); + Ok(()) + } } diff --git a/src/sync/util.rs b/src/sync/util.rs index d7d2295..976fbd8 100644 --- a/src/sync/util.rs +++ b/src/sync/util.rs @@ -216,7 +216,7 @@ fn format_identity_event(event: &SequencedEvent) -> Result, anyhow::Erro op: 1, t: "#identity".to_string(), }; - let mut bytes = Vec::new(); + let mut bytes = Vec::with_capacity(256); serde_ipld_dagcbor::to_writer(&mut bytes, &header)?; serde_ipld_dagcbor::to_writer(&mut bytes, &frame)?; Ok(bytes) @@ -234,7 +234,7 @@ fn format_account_event(event: &SequencedEvent) -> Result, anyhow::Error op: 1, t: "#account".to_string(), }; - let mut bytes = Vec::new(); + let mut bytes = Vec::with_capacity(256); serde_ipld_dagcbor::to_writer(&mut bytes, &header)?; serde_ipld_dagcbor::to_writer(&mut bytes, &frame)?; let hex_str: String = bytes.iter().map(|b| format!("{:02x}", b)).collect(); @@ -281,7 +281,7 @@ async fn format_sync_event( op: 1, t: "#sync".to_string(), }; - let mut bytes = Vec::new(); + let mut bytes = Vec::with_capacity(512); serde_ipld_dagcbor::to_writer(&mut bytes, &header)?; serde_ipld_dagcbor::to_writer(&mut bytes, &frame)?; Ok(bytes) @@ -349,7 +349,7 @@ pub async fn format_event_for_sending( op: 1, t: "#commit".to_string(), }; - let mut bytes = Vec::new(); + let mut bytes = Vec::with_capacity(frame.blocks.len() + 512); serde_ipld_dagcbor::to_writer(&mut bytes, &header)?; serde_ipld_dagcbor::to_writer(&mut bytes, &frame)?; Ok(bytes) @@ -385,7 +385,7 @@ pub async fn prefetch_blocks_for_events( return Ok(HashMap::new()); } let fetched = state.block_store.get_many(&all_cids).await?; - let mut blocks_map = HashMap::new(); + let mut blocks_map = HashMap::with_capacity(all_cids.len()); for (cid, data_opt) in all_cids.into_iter().zip(fetched.into_iter()) { if let Some(data) = data_opt { blocks_map.insert(cid, data); @@ -497,7 +497,7 @@ pub async fn format_event_with_prefetched_blocks( op: 1, t: "#commit".to_string(), }; - let mut bytes = Vec::new(); + let mut bytes = Vec::with_capacity(frame.blocks.len() + 512); serde_ipld_dagcbor::to_writer(&mut bytes, &header)?; serde_ipld_dagcbor::to_writer(&mut bytes, &frame)?; Ok(bytes) @@ -512,7 +512,7 @@ pub fn format_info_frame(name: &str, message: Option<&str>) -> Result, a name: name.to_string(), message: message.map(String::from), }; - let mut bytes = Vec::new(); + let mut bytes = Vec::with_capacity(128); serde_ipld_dagcbor::to_writer(&mut bytes, &header)?; serde_ipld_dagcbor::to_writer(&mut bytes, &frame)?; Ok(bytes) @@ -524,7 +524,7 @@ pub fn format_error_frame(error: &str, message: Option<&str>) -> Result, error: error.to_string(), message: message.map(String::from), }; - let mut bytes = Vec::new(); + let mut bytes = Vec::with_capacity(128); serde_ipld_dagcbor::to_writer(&mut bytes, &header)?; serde_ipld_dagcbor::to_writer(&mut bytes, &frame)?; Ok(bytes) diff --git a/src/sync/verify.rs b/src/sync/verify.rs index 6832ead..f5529b2 100644 --- a/src/sync/verify.rs +++ b/src/sync/verify.rs @@ -47,6 +47,9 @@ impl CarVerifier { Self { http_client: Client::builder() .timeout(std::time::Duration::from_secs(10)) + .connect_timeout(std::time::Duration::from_secs(5)) + .pool_max_idle_per_host(10) + .pool_idle_timeout(std::time::Duration::from_secs(90)) .build() .unwrap_or_default(), } diff --git a/src/sync/verify_tests.rs b/src/sync/verify_tests.rs index 68d271a..6f4345b 100644 --- a/src/sync/verify_tests.rs +++ b/src/sync/verify_tests.rs @@ -1,311 +1,308 @@ -#[cfg(test)] -mod tests { - use crate::sync::verify::{CarVerifier, VerifyError}; - use bytes::Bytes; - use cid::Cid; - use sha2::{Digest, Sha256}; - use std::collections::HashMap; +use crate::sync::verify::{CarVerifier, VerifyError}; +use bytes::Bytes; +use cid::Cid; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; - fn make_cid(data: &[u8]) -> Cid { - let mut hasher = Sha256::new(); - hasher.update(data); - let hash = hasher.finalize(); - let multihash = multihash::Multihash::wrap(0x12, &hash).unwrap(); - Cid::new_v1(0x71, multihash) - } +fn make_cid(data: &[u8]) -> Cid { + let mut hasher = Sha256::new(); + hasher.update(data); + let hash = hasher.finalize(); + let multihash = multihash::Multihash::wrap(0x12, &hash).unwrap(); + Cid::new_v1(0x71, multihash) +} - #[test] - fn test_verifier_creation() { - let _verifier = CarVerifier::new(); - } +#[test] +fn test_verifier_creation() { + let _verifier = CarVerifier::new(); +} - #[test] - fn test_verify_error_display() { - let err = VerifyError::DidMismatch { - commit_did: "did:plc:abc".to_string(), - expected_did: "did:plc:xyz".to_string(), - }; - assert!(err.to_string().contains("did:plc:abc")); - assert!(err.to_string().contains("did:plc:xyz")); - let err = VerifyError::InvalidSignature; - assert!(err.to_string().contains("signature")); - let err = VerifyError::NoSigningKey; - assert!(err.to_string().contains("signing key")); - let err = VerifyError::MstValidationFailed("test error".to_string()); - assert!(err.to_string().contains("test error")); - } +#[test] +fn test_verify_error_display() { + let err = VerifyError::DidMismatch { + commit_did: "did:plc:abc".to_string(), + expected_did: "did:plc:xyz".to_string(), + }; + assert!(err.to_string().contains("did:plc:abc")); + assert!(err.to_string().contains("did:plc:xyz")); + let err = VerifyError::InvalidSignature; + assert!(err.to_string().contains("signature")); + let err = VerifyError::NoSigningKey; + assert!(err.to_string().contains("signing key")); + let err = VerifyError::MstValidationFailed("test error".to_string()); + assert!(err.to_string().contains("test error")); +} - #[test] - fn test_mst_validation_missing_root_block() { - let verifier = CarVerifier::new(); - let blocks: HashMap = HashMap::new(); - let fake_cid = make_cid(b"fake data"); - let result = verifier.verify_mst_structure(&fake_cid, &blocks); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, VerifyError::BlockNotFound(_))); - } +#[test] +fn test_mst_validation_missing_root_block() { + let verifier = CarVerifier::new(); + let blocks: HashMap = HashMap::new(); + let fake_cid = make_cid(b"fake data"); + let result = verifier.verify_mst_structure(&fake_cid, &blocks); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, VerifyError::BlockNotFound(_))); +} - #[test] - fn test_mst_validation_invalid_cbor() { - let verifier = CarVerifier::new(); - let bad_cbor = Bytes::from(vec![0xFF, 0xFF, 0xFF]); - let cid = make_cid(&bad_cbor); - let mut blocks = HashMap::new(); - blocks.insert(cid, bad_cbor); - let result = verifier.verify_mst_structure(&cid, &blocks); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, VerifyError::InvalidCbor(_))); - } +#[test] +fn test_mst_validation_invalid_cbor() { + let verifier = CarVerifier::new(); + let bad_cbor = Bytes::from(vec![0xFF, 0xFF, 0xFF]); + let cid = make_cid(&bad_cbor); + let mut blocks = HashMap::new(); + blocks.insert(cid, bad_cbor); + let result = verifier.verify_mst_structure(&cid, &blocks); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, VerifyError::InvalidCbor(_))); +} - #[test] - fn test_mst_validation_empty_node() { - let verifier = CarVerifier::new(); - let empty_node = serde_ipld_dagcbor::to_vec(&serde_json::json!({ - "e": [] - })) - .unwrap(); - let cid = make_cid(&empty_node); - let mut blocks = HashMap::new(); - blocks.insert(cid, Bytes::from(empty_node)); - let result = verifier.verify_mst_structure(&cid, &blocks); - assert!(result.is_ok()); - } +#[test] +fn test_mst_validation_empty_node() { + let verifier = CarVerifier::new(); + let empty_node = serde_ipld_dagcbor::to_vec(&serde_json::json!({ + "e": [] + })) + .unwrap(); + let cid = make_cid(&empty_node); + let mut blocks = HashMap::new(); + blocks.insert(cid, Bytes::from(empty_node)); + let result = verifier.verify_mst_structure(&cid, &blocks); + assert!(result.is_ok()); +} - #[test] - fn test_mst_validation_missing_left_pointer() { - use ipld_core::ipld::Ipld; +#[test] +fn test_mst_validation_missing_left_pointer() { + use ipld_core::ipld::Ipld; - let verifier = CarVerifier::new(); - let missing_left_cid = make_cid(b"missing left"); - let node = Ipld::Map(std::collections::BTreeMap::from([ - ("l".to_string(), Ipld::Link(missing_left_cid)), - ("e".to_string(), Ipld::List(vec![])), - ])); - let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); - let cid = make_cid(&node_bytes); - let mut blocks = HashMap::new(); - blocks.insert(cid, Bytes::from(node_bytes)); - let result = verifier.verify_mst_structure(&cid, &blocks); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, VerifyError::BlockNotFound(_))); - assert!(err.to_string().contains("left pointer")); - } + let verifier = CarVerifier::new(); + let missing_left_cid = make_cid(b"missing left"); + let node = Ipld::Map(std::collections::BTreeMap::from([ + ("l".to_string(), Ipld::Link(missing_left_cid)), + ("e".to_string(), Ipld::List(vec![])), + ])); + let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); + let cid = make_cid(&node_bytes); + let mut blocks = HashMap::new(); + blocks.insert(cid, Bytes::from(node_bytes)); + let result = verifier.verify_mst_structure(&cid, &blocks); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, VerifyError::BlockNotFound(_))); + assert!(err.to_string().contains("left pointer")); +} - #[test] - fn test_mst_validation_missing_subtree() { - use ipld_core::ipld::Ipld; +#[test] +fn test_mst_validation_missing_subtree() { + use ipld_core::ipld::Ipld; - let verifier = CarVerifier::new(); - let missing_subtree_cid = make_cid(b"missing subtree"); - let record_cid = make_cid(b"record"); - let entry = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"key1".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(0)), - ("t".to_string(), Ipld::Link(missing_subtree_cid)), - ])); - let node = Ipld::Map(std::collections::BTreeMap::from([( - "e".to_string(), - Ipld::List(vec![entry]), - )])); - let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); - let cid = make_cid(&node_bytes); - let mut blocks = HashMap::new(); - blocks.insert(cid, Bytes::from(node_bytes)); - let result = verifier.verify_mst_structure(&cid, &blocks); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, VerifyError::BlockNotFound(_))); - assert!(err.to_string().contains("subtree")); - } + let verifier = CarVerifier::new(); + let missing_subtree_cid = make_cid(b"missing subtree"); + let record_cid = make_cid(b"record"); + let entry = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"key1".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(0)), + ("t".to_string(), Ipld::Link(missing_subtree_cid)), + ])); + let node = Ipld::Map(std::collections::BTreeMap::from([( + "e".to_string(), + Ipld::List(vec![entry]), + )])); + let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); + let cid = make_cid(&node_bytes); + let mut blocks = HashMap::new(); + blocks.insert(cid, Bytes::from(node_bytes)); + let result = verifier.verify_mst_structure(&cid, &blocks); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, VerifyError::BlockNotFound(_))); + assert!(err.to_string().contains("subtree")); +} - #[test] - fn test_mst_validation_unsorted_keys() { - use ipld_core::ipld::Ipld; +#[test] +fn test_mst_validation_unsorted_keys() { + use ipld_core::ipld::Ipld; - let verifier = CarVerifier::new(); - let record_cid = make_cid(b"record"); - let entry1 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"zzz".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(0)), - ])); - let entry2 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"aaa".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(0)), - ])); - let node = Ipld::Map(std::collections::BTreeMap::from([( - "e".to_string(), - Ipld::List(vec![entry1, entry2]), - )])); - let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); - let cid = make_cid(&node_bytes); - let mut blocks = HashMap::new(); - blocks.insert(cid, Bytes::from(node_bytes)); - let result = verifier.verify_mst_structure(&cid, &blocks); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, VerifyError::MstValidationFailed(_))); - assert!(err.to_string().contains("sorted")); - } + let verifier = CarVerifier::new(); + let record_cid = make_cid(b"record"); + let entry1 = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"zzz".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(0)), + ])); + let entry2 = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"aaa".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(0)), + ])); + let node = Ipld::Map(std::collections::BTreeMap::from([( + "e".to_string(), + Ipld::List(vec![entry1, entry2]), + )])); + let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); + let cid = make_cid(&node_bytes); + let mut blocks = HashMap::new(); + blocks.insert(cid, Bytes::from(node_bytes)); + let result = verifier.verify_mst_structure(&cid, &blocks); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, VerifyError::MstValidationFailed(_))); + assert!(err.to_string().contains("sorted")); +} - #[test] - fn test_mst_validation_sorted_keys_ok() { - use ipld_core::ipld::Ipld; +#[test] +fn test_mst_validation_sorted_keys_ok() { + use ipld_core::ipld::Ipld; - let verifier = CarVerifier::new(); - let record_cid = make_cid(b"record"); - let entry1 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"aaa".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(0)), - ])); - let entry2 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"bbb".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(0)), - ])); - let entry3 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"zzz".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(0)), - ])); - let node = Ipld::Map(std::collections::BTreeMap::from([( - "e".to_string(), - Ipld::List(vec![entry1, entry2, entry3]), - )])); - let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); - let cid = make_cid(&node_bytes); - let mut blocks = HashMap::new(); - blocks.insert(cid, Bytes::from(node_bytes)); - let result = verifier.verify_mst_structure(&cid, &blocks); - assert!(result.is_ok()); - } + let verifier = CarVerifier::new(); + let record_cid = make_cid(b"record"); + let entry1 = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"aaa".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(0)), + ])); + let entry2 = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"bbb".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(0)), + ])); + let entry3 = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"zzz".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(0)), + ])); + let node = Ipld::Map(std::collections::BTreeMap::from([( + "e".to_string(), + Ipld::List(vec![entry1, entry2, entry3]), + )])); + let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); + let cid = make_cid(&node_bytes); + let mut blocks = HashMap::new(); + blocks.insert(cid, Bytes::from(node_bytes)); + let result = verifier.verify_mst_structure(&cid, &blocks); + assert!(result.is_ok()); +} - #[test] - fn test_mst_validation_with_valid_left_pointer() { - use ipld_core::ipld::Ipld; +#[test] +fn test_mst_validation_with_valid_left_pointer() { + use ipld_core::ipld::Ipld; - let verifier = CarVerifier::new(); - let left_node = Ipld::Map(std::collections::BTreeMap::from([( - "e".to_string(), - Ipld::List(vec![]), - )])); - let left_node_bytes = serde_ipld_dagcbor::to_vec(&left_node).unwrap(); - let left_cid = make_cid(&left_node_bytes); - let root_node = Ipld::Map(std::collections::BTreeMap::from([ - ("l".to_string(), Ipld::Link(left_cid)), - ("e".to_string(), Ipld::List(vec![])), - ])); - let root_node_bytes = serde_ipld_dagcbor::to_vec(&root_node).unwrap(); - let root_cid = make_cid(&root_node_bytes); - let mut blocks = HashMap::new(); - blocks.insert(root_cid, Bytes::from(root_node_bytes)); - blocks.insert(left_cid, Bytes::from(left_node_bytes)); - let result = verifier.verify_mst_structure(&root_cid, &blocks); - assert!(result.is_ok()); - } + let verifier = CarVerifier::new(); + let left_node = Ipld::Map(std::collections::BTreeMap::from([( + "e".to_string(), + Ipld::List(vec![]), + )])); + let left_node_bytes = serde_ipld_dagcbor::to_vec(&left_node).unwrap(); + let left_cid = make_cid(&left_node_bytes); + let root_node = Ipld::Map(std::collections::BTreeMap::from([ + ("l".to_string(), Ipld::Link(left_cid)), + ("e".to_string(), Ipld::List(vec![])), + ])); + let root_node_bytes = serde_ipld_dagcbor::to_vec(&root_node).unwrap(); + let root_cid = make_cid(&root_node_bytes); + let mut blocks = HashMap::new(); + blocks.insert(root_cid, Bytes::from(root_node_bytes)); + blocks.insert(left_cid, Bytes::from(left_node_bytes)); + let result = verifier.verify_mst_structure(&root_cid, &blocks); + assert!(result.is_ok()); +} - #[test] - fn test_mst_validation_cycle_detection() { - let verifier = CarVerifier::new(); - let node = serde_ipld_dagcbor::to_vec(&serde_json::json!({ - "e": [] - })) - .unwrap(); - let cid = make_cid(&node); - let mut blocks = HashMap::new(); - blocks.insert(cid, Bytes::from(node)); - let result = verifier.verify_mst_structure(&cid, &blocks); - assert!(result.is_ok()); - } +#[test] +fn test_mst_validation_cycle_detection() { + let verifier = CarVerifier::new(); + let node = serde_ipld_dagcbor::to_vec(&serde_json::json!({ + "e": [] + })) + .unwrap(); + let cid = make_cid(&node); + let mut blocks = HashMap::new(); + blocks.insert(cid, Bytes::from(node)); + let result = verifier.verify_mst_structure(&cid, &blocks); + assert!(result.is_ok()); +} - #[tokio::test] - async fn test_unsupported_did_method() { - let verifier = CarVerifier::new(); - let result = verifier.resolve_did_document("did:unknown:test").await; - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, VerifyError::DidResolutionFailed(_))); - assert!(err.to_string().contains("Unsupported")); - } +#[tokio::test] +async fn test_unsupported_did_method() { + let verifier = CarVerifier::new(); + let result = verifier.resolve_did_document("did:unknown:test").await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, VerifyError::DidResolutionFailed(_))); + assert!(err.to_string().contains("Unsupported")); +} - #[test] - fn test_mst_validation_with_prefix_compression() { - use ipld_core::ipld::Ipld; +#[test] +fn test_mst_validation_with_prefix_compression() { + use ipld_core::ipld::Ipld; - let verifier = CarVerifier::new(); - let record_cid = make_cid(b"record"); - let entry1 = Ipld::Map(std::collections::BTreeMap::from([ - ( - "k".to_string(), - Ipld::Bytes(b"app.bsky.feed.post/abc".to_vec()), - ), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(0)), - ])); - let entry2 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"def".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(19)), - ])); - let entry3 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"xyz".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(19)), - ])); - let node = Ipld::Map(std::collections::BTreeMap::from([( - "e".to_string(), - Ipld::List(vec![entry1, entry2, entry3]), - )])); - let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); - let cid = make_cid(&node_bytes); - let mut blocks = HashMap::new(); - blocks.insert(cid, Bytes::from(node_bytes)); - let result = verifier.verify_mst_structure(&cid, &blocks); - assert!( - result.is_ok(), - "Prefix-compressed keys should be validated correctly" - ); - } + let verifier = CarVerifier::new(); + let record_cid = make_cid(b"record"); + let entry1 = Ipld::Map(std::collections::BTreeMap::from([ + ( + "k".to_string(), + Ipld::Bytes(b"app.bsky.feed.post/abc".to_vec()), + ), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(0)), + ])); + let entry2 = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"def".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(19)), + ])); + let entry3 = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"xyz".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(19)), + ])); + let node = Ipld::Map(std::collections::BTreeMap::from([( + "e".to_string(), + Ipld::List(vec![entry1, entry2, entry3]), + )])); + let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); + let cid = make_cid(&node_bytes); + let mut blocks = HashMap::new(); + blocks.insert(cid, Bytes::from(node_bytes)); + let result = verifier.verify_mst_structure(&cid, &blocks); + assert!( + result.is_ok(), + "Prefix-compressed keys should be validated correctly" + ); +} - #[test] - fn test_mst_validation_prefix_compression_unsorted() { - use ipld_core::ipld::Ipld; +#[test] +fn test_mst_validation_prefix_compression_unsorted() { + use ipld_core::ipld::Ipld; - let verifier = CarVerifier::new(); - let record_cid = make_cid(b"record"); - let entry1 = Ipld::Map(std::collections::BTreeMap::from([ - ( - "k".to_string(), - Ipld::Bytes(b"app.bsky.feed.post/xyz".to_vec()), - ), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(0)), - ])); - let entry2 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"abc".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(19)), - ])); - let node = Ipld::Map(std::collections::BTreeMap::from([( - "e".to_string(), - Ipld::List(vec![entry1, entry2]), - )])); - let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); - let cid = make_cid(&node_bytes); - let mut blocks = HashMap::new(); - blocks.insert(cid, Bytes::from(node_bytes)); - let result = verifier.verify_mst_structure(&cid, &blocks); - assert!( - result.is_err(), - "Unsorted prefix-compressed keys should fail validation" - ); - let err = result.unwrap_err(); - assert!(matches!(err, VerifyError::MstValidationFailed(_))); - } + let verifier = CarVerifier::new(); + let record_cid = make_cid(b"record"); + let entry1 = Ipld::Map(std::collections::BTreeMap::from([ + ( + "k".to_string(), + Ipld::Bytes(b"app.bsky.feed.post/xyz".to_vec()), + ), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(0)), + ])); + let entry2 = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"abc".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(19)), + ])); + let node = Ipld::Map(std::collections::BTreeMap::from([( + "e".to_string(), + Ipld::List(vec![entry1, entry2]), + )])); + let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); + let cid = make_cid(&node_bytes); + let mut blocks = HashMap::new(); + blocks.insert(cid, Bytes::from(node_bytes)); + let result = verifier.verify_mst_structure(&cid, &blocks); + assert!( + result.is_err(), + "Unsorted prefix-compressed keys should fail validation" + ); + let err = result.unwrap_err(); + assert!(matches!(err, VerifyError::MstValidationFailed(_))); } diff --git a/tests/account_lifecycle.rs b/tests/account_lifecycle.rs index c07203f..c6ed426 100644 --- a/tests/account_lifecycle.rs +++ b/tests/account_lifecycle.rs @@ -48,7 +48,7 @@ async fn test_check_account_status_returns_correct_block_count() { .as_str() .unwrap() .split('/') - .last() + .next_back() .unwrap() .to_string(); diff --git a/tests/backup.rs b/tests/backup.rs index 557f9f7..f9839a9 100644 --- a/tests/backup.rs +++ b/tests/backup.rs @@ -53,7 +53,7 @@ async fn test_create_and_list_backup() { assert_eq!(list_res.status(), StatusCode::OK); let list_body: Value = list_res.json().await.expect("Invalid JSON"); let backups = list_body["backups"].as_array().unwrap(); - assert!(backups.len() >= 1); + assert!(!backups.is_empty()); } #[tokio::test] diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 4607254..1f319db 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -52,11 +52,11 @@ fn cleanup() { } if std::env::var("XDG_RUNTIME_DIR").is_ok() { let _ = std::process::Command::new("podman") - .args(&["rm", "-f", "--filter", "label=tranquil_pds_test=true"]) + .args(["rm", "-f", "--filter", "label=tranquil_pds_test=true"]) .output(); } let _ = std::process::Command::new("docker") - .args(&[ + .args([ "container", "prune", "-f", @@ -83,16 +83,16 @@ pub async fn base_url() -> &'static str { unsafe { std::env::set_var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", "1"); } - if std::env::var("DOCKER_HOST").is_err() { - if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { - let podman_sock = std::path::Path::new(&runtime_dir).join("podman/podman.sock"); - if podman_sock.exists() { - unsafe { - std::env::set_var( - "DOCKER_HOST", - format!("unix://{}", podman_sock.display()), - ); - } + if std::env::var("DOCKER_HOST").is_err() + && let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") + { + let podman_sock = std::path::Path::new(&runtime_dir).join("podman/podman.sock"); + if podman_sock.exists() { + unsafe { + std::env::set_var( + "DOCKER_HOST", + format!("unix://{}", podman_sock.display()), + ); } } } @@ -135,6 +135,7 @@ async fn setup_with_external_infra() -> String { std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string()), ); std::env::set_var("S3_ENDPOINT", &s3_endpoint); + std::env::set_var("MAX_IMPORT_SIZE", "100000000"); } let mock_server = MockServer::start().await; setup_mock_appview(&mock_server).await; @@ -168,6 +169,7 @@ async fn setup_with_testcontainers() -> String { std::env::set_var("AWS_SECRET_ACCESS_KEY", "minioadmin"); std::env::set_var("AWS_REGION", "us-east-1"); std::env::set_var("S3_ENDPOINT", &s3_endpoint); + std::env::set_var("MAX_IMPORT_SIZE", "100000000"); } let sdk_config = aws_config::defaults(BehaviorVersion::latest()) .region("us-east-1") @@ -418,7 +420,7 @@ pub async fn create_test_post( .to_string(); let rkey = uri .split('/') - .last() + .next_back() .expect("URI was malformed") .to_string(); (uri, cid, rkey) @@ -472,10 +474,10 @@ async fn create_account_and_login_internal(client: &Client, make_admin: bool) -> .expect("Failed to mark user as admin"); } let verification_required = body["verificationRequired"].as_bool().unwrap_or(true); - if let Some(access_jwt) = body["accessJwt"].as_str() { - if !verification_required { - return (access_jwt.to_string(), did); - } + if let Some(access_jwt) = body["accessJwt"].as_str() + && !verification_required + { + return (access_jwt.to_string(), did); } let body_text: String = sqlx::query_scalar!( "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1", @@ -488,17 +490,17 @@ async fn create_account_and_login_internal(client: &Client, make_admin: bool) -> let verification_code = lines .iter() .enumerate() - .find(|(_, line)| { + .find(|(_, line): &(usize, &&str)| { line.contains("verification code is:") || line.contains("code is:") }) - .and_then(|(i, _)| lines.get(i + 1).map(|s| s.trim().to_string())) + .and_then(|(i, _)| lines.get(i + 1).map(|s: &&str| s.trim().to_string())) .or_else(|| { body_text .split_whitespace() - .find(|word| { + .find(|word: &&str| { word.contains('-') && word.chars().filter(|c| *c == '-').count() >= 3 }) - .map(|s| s.to_string()) + .map(|s: &str| s.to_string()) }) .unwrap_or_else(|| body_text.clone()); diff --git a/tests/delete_account.rs b/tests/delete_account.rs index 7132539..c94c8f5 100644 --- a/tests/delete_account.rs +++ b/tests/delete_account.rs @@ -40,7 +40,7 @@ async fn test_delete_account_full_flow() { let handle = format!("delete-test-{}.test", ts); let email = format!("delete-test-{}@test.com", ts); let password = "Delete123pass!"; - let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await; + let (did, jwt) = create_verified_account(&client, base_url, &handle, &email, password).await; let request_delete_res = client .post(format!( "{}/xrpc/com.atproto.server.requestAccountDelete", @@ -97,7 +97,7 @@ async fn test_delete_account_wrong_password() { let handle = format!("delete-wrongpw-{}.test", ts); let email = format!("delete-wrongpw-{}@test.com", ts); let password = "Correct123!"; - let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await; + let (did, jwt) = create_verified_account(&client, base_url, &handle, &email, password).await; let request_delete_res = client .post(format!( "{}/xrpc/com.atproto.server.requestAccountDelete", @@ -187,7 +187,7 @@ async fn test_delete_account_expired_token() { let handle = format!("delete-expired-{}.test", ts); let email = format!("delete-expired-{}@test.com", ts); let password = "Delete123!"; - let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await; + let (did, jwt) = create_verified_account(&client, base_url, &handle, &email, password).await; let request_delete_res = client .post(format!( "{}/xrpc/com.atproto.server.requestAccountDelete", @@ -242,11 +242,11 @@ async fn test_delete_account_token_mismatch() { let email1 = format!("delete-user1-{}@test.com", ts); let password1 = "User1pass123!"; let (did1, jwt1) = - create_verified_account(&client, &base_url, &handle1, &email1, password1).await; + create_verified_account(&client, base_url, &handle1, &email1, password1).await; let handle2 = format!("delete-user2-{}.test", ts); let email2 = format!("delete-user2-{}@test.com", ts); let password2 = "User2pass123!"; - let (did2, _) = create_verified_account(&client, &base_url, &handle2, &email2, password2).await; + let (did2, _) = create_verified_account(&client, base_url, &handle2, &email2, password2).await; let request_delete_res = client .post(format!( "{}/xrpc/com.atproto.server.requestAccountDelete", @@ -294,7 +294,7 @@ async fn test_delete_account_with_app_password() { let email = format!("delete-apppw-{}@test.com", ts); let main_password = "Mainpass123!"; let (did, jwt) = - create_verified_account(&client, &base_url, &handle, &email, main_password).await; + create_verified_account(&client, base_url, &handle, &email, main_password).await; let app_password_res = client .post(format!( "{}/xrpc/com.atproto.server.createAppPassword", diff --git a/tests/dpop_unit.rs b/tests/dpop_unit.rs new file mode 100644 index 0000000..506ea5f --- /dev/null +++ b/tests/dpop_unit.rs @@ -0,0 +1,537 @@ +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use chrono::Utc; +use p256::ecdsa::{SigningKey, signature::Signer}; +use serde_json::json; + +use tranquil_pds::oauth::dpop::{ + DPoPJwk, DPoPVerifier, compute_access_token_hash, compute_jwk_thumbprint, +}; + +fn create_dpop_proof( + method: &str, + htu: &str, + iat_offset_secs: i64, + alg: &str, + nonce: Option<&str>, + ath: Option<&str>, +) -> (String, p256::ecdsa::VerifyingKey) { + let signing_key = SigningKey::random(&mut rand::thread_rng()); + let verifying_key = *signing_key.verifying_key(); + let point = verifying_key.to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); + + let header = json!({ + "typ": "dpop+jwt", + "alg": alg, + "jwk": { + "kty": "EC", + "crv": "P-256", + "x": x, + "y": y + } + }); + + let iat = Utc::now().timestamp() + iat_offset_secs; + let jti = uuid::Uuid::new_v4().to_string(); + + let mut payload = json!({ + "jti": jti, + "htm": method, + "htu": htu, + "iat": iat + }); + + if let Some(n) = nonce { + payload["nonce"] = json!(n); + } + if let Some(a) = ath { + payload["ath"] = json!(a); + } + + let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes()); + let payload_b64 = URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes()); + let signing_input = format!("{}.{}", header_b64, payload_b64); + + let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + let proof = format!("{}.{}.{}", header_b64, payload_b64, sig_b64); + (proof, verifying_key) +} + +fn create_dpop_proof_with_invalid_sig(method: &str, htu: &str, alg: &str) -> String { + let signing_key = SigningKey::random(&mut rand::thread_rng()); + let verifying_key = *signing_key.verifying_key(); + let point = verifying_key.to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); + + let header = json!({ + "typ": "dpop+jwt", + "alg": alg, + "jwk": { + "kty": "EC", + "crv": "P-256", + "x": x, + "y": y + } + }); + + let iat = Utc::now().timestamp(); + let jti = uuid::Uuid::new_v4().to_string(); + + let payload = json!({ + "jti": jti, + "htm": method, + "htu": htu, + "iat": iat + }); + + let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes()); + let payload_b64 = URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes()); + + let fake_sig = URL_SAFE_NO_PAD.encode(vec![0u8; 64]); + + format!("{}.{}.{}", header_b64, payload_b64, fake_sig) +} + +#[test] +fn test_dpop_htu_query_params_stripped() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let url_with_query = "https://pds.example/xrpc/com.atproto.server.getSession?foo=bar"; + let url_without_query = "https://pds.example/xrpc/com.atproto.server.getSession"; + + let (proof, _) = create_dpop_proof("GET", url_with_query, 0, "ES256", None, None); + let result = verifier.verify_proof(&proof, "GET", url_without_query, None); + assert!( + result.is_ok(), + "Query params in htu should be stripped for comparison" + ); +} + +#[test] +fn test_dpop_htu_fragment_behavior() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let url_with_fragment = "https://pds.example/xrpc/foo#fragment"; + let url_without_fragment = "https://pds.example/xrpc/foo"; + + let (proof, _) = create_dpop_proof("GET", url_with_fragment, 0, "ES256", None, None); + let result = verifier.verify_proof(&proof, "GET", url_without_fragment, None); + + assert!( + result.is_err(), + "Fragment in htu should cause mismatch (currently NOT stripped)" + ); +} + +#[test] +fn test_dpop_es512_algorithm_rejected() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let url = "https://pds.example/xrpc/foo"; + + let signing_key = SigningKey::random(&mut rand::thread_rng()); + let verifying_key = *signing_key.verifying_key(); + let point = verifying_key.to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); + + let header = json!({ + "typ": "dpop+jwt", + "alg": "ES512", + "jwk": { + "kty": "EC", + "crv": "P-256", + "x": x, + "y": y + } + }); + + let payload = json!({ + "jti": uuid::Uuid::new_v4().to_string(), + "htm": "GET", + "htu": url, + "iat": Utc::now().timestamp() + }); + + let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes()); + let payload_b64 = URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes()); + let signing_input = format!("{}.{}", header_b64, payload_b64); + let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + let proof = format!("{}.{}.{}", header_b64, payload_b64, sig_b64); + + let result = verifier.verify_proof(&proof, "GET", url, None); + assert!(result.is_err(), "ES512 should be rejected as unsupported"); +} + +#[test] +fn test_dpop_iat_clock_skew_within_bounds() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let url = "https://pds.example/xrpc/foo"; + + let (proof_299s_future, _) = create_dpop_proof("GET", url, 299, "ES256", None, None); + let result = verifier.verify_proof(&proof_299s_future, "GET", url, None); + assert!( + result.is_ok(), + "299s in future should be within clock skew tolerance" + ); + + let (proof_299s_past, _) = create_dpop_proof("GET", url, -299, "ES256", None, None); + let result = verifier.verify_proof(&proof_299s_past, "GET", url, None); + assert!( + result.is_ok(), + "299s in past should be within clock skew tolerance" + ); +} + +#[test] +fn test_dpop_iat_clock_skew_beyond_bounds() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let url = "https://pds.example/xrpc/foo"; + + let (proof_301s_future, _) = create_dpop_proof("GET", url, 301, "ES256", None, None); + let result = verifier.verify_proof(&proof_301s_future, "GET", url, None); + assert!( + result.is_err(), + "301s in future should exceed clock skew tolerance" + ); + + let (proof_301s_past, _) = create_dpop_proof("GET", url, -301, "ES256", None, None); + let result = verifier.verify_proof(&proof_301s_past, "GET", url, None); + assert!( + result.is_err(), + "301s in past should exceed clock skew tolerance" + ); +} + +#[test] +fn test_dpop_http_method_case_insensitive() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let url = "https://pds.example/xrpc/foo"; + + let (proof_lowercase, _) = create_dpop_proof("get", url, 0, "ES256", None, None); + let result = verifier.verify_proof(&proof_lowercase, "GET", url, None); + assert!( + result.is_ok(), + "HTTP method comparison should be case-insensitive" + ); + + let (proof_mixed, _) = create_dpop_proof("GeT", url, 0, "ES256", None, None); + let result = verifier.verify_proof(&proof_mixed, "GET", url, None); + assert!( + result.is_ok(), + "HTTP method comparison should be case-insensitive" + ); +} + +#[test] +fn test_dpop_http_method_mismatch() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let url = "https://pds.example/xrpc/foo"; + + let (proof_post, _) = create_dpop_proof("POST", url, 0, "ES256", None, None); + let result = verifier.verify_proof(&proof_post, "GET", url, None); + assert!(result.is_err(), "HTTP method mismatch should fail"); +} + +#[test] +fn test_dpop_invalid_signature() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let url = "https://pds.example/xrpc/foo"; + + let proof = create_dpop_proof_with_invalid_sig("GET", url, "ES256"); + let result = verifier.verify_proof(&proof, "GET", url, None); + assert!(result.is_err(), "Invalid signature should be rejected"); +} + +#[test] +fn test_dpop_malformed_base64() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let result = verifier.verify_proof("not.valid.base64!!!", "GET", "https://example.com", None); + assert!(result.is_err(), "Malformed base64 should be rejected"); +} + +#[test] +fn test_dpop_missing_parts() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + + let result = verifier.verify_proof("onlyonepart", "GET", "https://example.com", None); + assert!( + result.is_err(), + "DPoP with missing parts should be rejected" + ); + + let result = verifier.verify_proof("two.parts", "GET", "https://example.com", None); + assert!( + result.is_err(), + "DPoP with only two parts should be rejected" + ); +} + +#[test] +fn test_dpop_invalid_typ() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let url = "https://pds.example/xrpc/foo"; + + let signing_key = SigningKey::random(&mut rand::thread_rng()); + let verifying_key = *signing_key.verifying_key(); + let point = verifying_key.to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); + + let header = json!({ + "typ": "jwt", + "alg": "ES256", + "jwk": { + "kty": "EC", + "crv": "P-256", + "x": x, + "y": y + } + }); + + let payload = json!({ + "jti": uuid::Uuid::new_v4().to_string(), + "htm": "GET", + "htu": url, + "iat": Utc::now().timestamp() + }); + + let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes()); + let payload_b64 = URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes()); + let signing_input = format!("{}.{}", header_b64, payload_b64); + let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + let proof = format!("{}.{}.{}", header_b64, payload_b64, sig_b64); + + let result = verifier.verify_proof(&proof, "GET", url, None); + assert!(result.is_err(), "Invalid typ claim should be rejected"); +} + +#[test] +fn test_dpop_unsupported_algorithm() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let url = "https://pds.example/xrpc/foo"; + + let signing_key = SigningKey::random(&mut rand::thread_rng()); + let verifying_key = *signing_key.verifying_key(); + let point = verifying_key.to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); + + let header = json!({ + "typ": "dpop+jwt", + "alg": "RS256", + "jwk": { + "kty": "EC", + "crv": "P-256", + "x": x, + "y": y + } + }); + + let payload = json!({ + "jti": uuid::Uuid::new_v4().to_string(), + "htm": "GET", + "htu": url, + "iat": Utc::now().timestamp() + }); + + let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes()); + let payload_b64 = URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes()); + let signing_input = format!("{}.{}", header_b64, payload_b64); + let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + let proof = format!("{}.{}.{}", header_b64, payload_b64, sig_b64); + + let result = verifier.verify_proof(&proof, "GET", url, None); + assert!(result.is_err(), "Unsupported algorithm should be rejected"); +} + +#[test] +fn test_dpop_access_token_hash() { + let token = "test-access-token"; + let hash = compute_access_token_hash(token); + assert!(!hash.is_empty()); + + let hash2 = compute_access_token_hash(token); + assert_eq!(hash, hash2, "Same token should produce same hash"); + + let hash3 = compute_access_token_hash("different-token"); + assert_ne!(hash, hash3, "Different token should produce different hash"); +} + +#[test] +fn test_dpop_nonce_generation_and_validation() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let nonce = verifier.generate_nonce(); + assert!(!nonce.is_empty()); + + let result = verifier.validate_nonce(&nonce); + assert!(result.is_ok(), "Freshly generated nonce should be valid"); +} + +#[test] +fn test_dpop_nonce_invalid_encoding() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let result = verifier.validate_nonce("not-valid-base64!!!"); + assert!(result.is_err(), "Invalid base64 nonce should be rejected"); +} + +#[test] +fn test_dpop_nonce_too_short() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let short_nonce = URL_SAFE_NO_PAD.encode(vec![0u8; 10]); + let result = verifier.validate_nonce(&short_nonce); + assert!(result.is_err(), "Too short nonce should be rejected"); +} + +#[test] +fn test_dpop_nonce_tampered_signature() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let nonce = verifier.generate_nonce(); + + let nonce_bytes = URL_SAFE_NO_PAD.decode(&nonce).unwrap(); + let mut tampered = nonce_bytes.clone(); + tampered[10] ^= 0xFF; + let tampered_nonce = URL_SAFE_NO_PAD.encode(&tampered); + + let result = verifier.validate_nonce(&tampered_nonce); + assert!(result.is_err(), "Tampered nonce should be rejected"); +} + +#[test] +fn test_jwk_thumbprint_ec() { + let jwk = DPoPJwk { + kty: "EC".to_string(), + crv: Some("P-256".to_string()), + x: Some("test_x".to_string()), + y: Some("test_y".to_string()), + }; + let thumbprint = compute_jwk_thumbprint(&jwk).unwrap(); + assert!(!thumbprint.is_empty()); + + let thumbprint2 = compute_jwk_thumbprint(&jwk).unwrap(); + assert_eq!( + thumbprint, thumbprint2, + "Same JWK should produce same thumbprint" + ); +} + +#[test] +fn test_jwk_thumbprint_okp() { + let jwk = DPoPJwk { + kty: "OKP".to_string(), + crv: Some("Ed25519".to_string()), + x: Some("test_x".to_string()), + y: None, + }; + let thumbprint = compute_jwk_thumbprint(&jwk).unwrap(); + assert!(!thumbprint.is_empty()); +} + +#[test] +fn test_jwk_thumbprint_unsupported_kty() { + let jwk = DPoPJwk { + kty: "RSA".to_string(), + crv: None, + x: None, + y: None, + }; + let result = compute_jwk_thumbprint(&jwk); + assert!(result.is_err(), "Unsupported key type should error"); +} + +#[test] +fn test_jwk_thumbprint_missing_fields() { + let jwk = DPoPJwk { + kty: "EC".to_string(), + crv: None, + x: None, + y: None, + }; + let result = compute_jwk_thumbprint(&jwk); + assert!(result.is_err(), "Missing crv should error"); +} + +#[test] +fn test_dpop_uri_normalization_preserves_port() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let url_with_port = "https://pds.example:8080/xrpc/foo"; + + let (proof, _) = create_dpop_proof("GET", url_with_port, 0, "ES256", None, None); + let result = verifier.verify_proof(&proof, "GET", url_with_port, None); + assert!(result.is_ok(), "URL with port should work"); + + let url_without_port = "https://pds.example/xrpc/foo"; + let result = verifier.verify_proof(&proof, "GET", url_without_port, None); + assert!(result.is_err(), "Different port should fail"); +} + +#[test] +fn test_dpop_uri_normalization_preserves_path() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let url = "https://pds.example/xrpc/com.atproto.server.getSession"; + + let (proof, _) = create_dpop_proof("GET", url, 0, "ES256", None, None); + + let different_path = "https://pds.example/xrpc/com.atproto.server.refreshSession"; + let result = verifier.verify_proof(&proof, "GET", different_path, None); + assert!(result.is_err(), "Different path should fail"); +} + +#[test] +fn test_dpop_htu_must_be_full_url_not_path() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let full_url = "https://pds.example/xrpc/com.atproto.server.getSession"; + let path_only = "/xrpc/com.atproto.server.getSession"; + + let (proof_with_path, _) = create_dpop_proof("GET", path_only, 0, "ES256", None, None); + let result = verifier.verify_proof(&proof_with_path, "GET", full_url, None); + assert!( + result.is_err(), + "htu with path-only should not match full URL" + ); + + let (proof_with_full, _) = create_dpop_proof("GET", full_url, 0, "ES256", None, None); + let result = verifier.verify_proof(&proof_with_full, "GET", full_url, None); + assert!(result.is_ok(), "htu with full URL should match"); +} + +#[test] +fn test_dpop_htu_scheme_must_match() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let https_url = "https://pds.example/xrpc/foo"; + let http_url = "http://pds.example/xrpc/foo"; + + let (proof, _) = create_dpop_proof("GET", http_url, 0, "ES256", None, None); + let result = verifier.verify_proof(&proof, "GET", https_url, None); + assert!(result.is_err(), "HTTP vs HTTPS scheme mismatch should fail"); +} + +#[test] +fn test_dpop_htu_host_must_match() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let url1 = "https://pds1.example/xrpc/foo"; + let url2 = "https://pds2.example/xrpc/foo"; + + let (proof, _) = create_dpop_proof("GET", url1, 0, "ES256", None, None); + let result = verifier.verify_proof(&proof, "GET", url2, None); + assert!(result.is_err(), "Different host should fail"); +} + +#[test] +fn test_dpop_server_must_check_full_url_not_path() { + let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); + let full_url = "https://pds.example/xrpc/com.atproto.server.getSession"; + let path_only = "/xrpc/com.atproto.server.getSession"; + + let (proof, _) = create_dpop_proof("GET", full_url, 0, "ES256", None, None); + let result = verifier.verify_proof(&proof, "GET", path_only, None); + assert!( + result.is_err(), + "Server checking path-only against full URL htu should fail" + ); +} diff --git a/tests/email_update.rs b/tests/email_update.rs index 5630b5b..8b2c374 100644 --- a/tests/email_update.rs +++ b/tests/email_update.rs @@ -59,7 +59,7 @@ async fn test_request_email_update_returns_token_required() { let base_url = common::base_url().await; let handle = format!("er{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); let email = format!("{}@example.com", handle); - let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; + let (access_jwt, _) = create_verified_account(&client, base_url, &handle, &email).await; let res = client .post(format!( @@ -82,7 +82,7 @@ async fn test_update_email_flow_success() { let pool = common::get_test_db_pool().await; let handle = format!("eu{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); let email = format!("{}@example.com", handle); - let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; + let (access_jwt, did) = create_verified_account(&client, base_url, &handle, &email).await; let new_email = format!("new_{}@example.com", handle); let res = client @@ -126,7 +126,7 @@ async fn test_update_email_requires_token_when_verified() { let base_url = common::base_url().await; let handle = format!("ed{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); let email = format!("{}@example.com", handle); - let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; + let (access_jwt, _) = create_verified_account(&client, base_url, &handle, &email).await; let new_email = format!("direct_{}@example.com", handle); let res = client @@ -147,7 +147,7 @@ async fn test_update_email_same_email_noop() { let base_url = common::base_url().await; let handle = format!("es{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); let email = format!("{}@example.com", handle); - let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; + let (access_jwt, _) = create_verified_account(&client, base_url, &handle, &email).await; let res = client .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) @@ -169,7 +169,7 @@ async fn test_update_email_invalid_token() { let base_url = common::base_url().await; let handle = format!("eb{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); let email = format!("{}@example.com", handle); - let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; + let (access_jwt, _) = create_verified_account(&client, base_url, &handle, &email).await; let new_email = format!("badtok_{}@example.com", handle); let res = client @@ -220,7 +220,7 @@ async fn test_update_email_invalid_format() { let base_url = common::base_url().await; let handle = format!("ef{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); let email = format!("{}@example.com", handle); - let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; + let (access_jwt, _) = create_verified_account(&client, base_url, &handle, &email).await; let res = client .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) @@ -470,11 +470,11 @@ async fn test_update_email_taken_by_another_user() { let handle1 = format!("d1{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); let email1 = format!("{}@example.com", handle1); - let (_, _) = create_verified_account(&client, &base_url, &handle1, &email1).await; + let (_, _) = create_verified_account(&client, base_url, &handle1, &email1).await; let handle2 = format!("d2{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); let email2 = format!("{}@example.com", handle2); - let (access_jwt2, did2) = create_verified_account(&client, &base_url, &handle2, &email2).await; + let (access_jwt2, did2) = create_verified_account(&client, base_url, &handle2, &email2).await; let res = client .post(format!( diff --git a/tests/firehose_validation.rs b/tests/firehose_validation.rs index 3e69705..62da708 100644 --- a/tests/firehose_validation.rs +++ b/tests/firehose_validation.rs @@ -232,11 +232,11 @@ async fn test_firehose_frame_structure() { tungstenite::Message::Binary(bin) => bin, _ => continue, }; - if let Ok((h, f)) = parse_frame(&raw_bytes) { - if f.repo == did { - frame_opt = Some((h, f)); - break; - } + if let Ok((h, f)) = parse_frame(&raw_bytes) + && f.repo == did + { + frame_opt = Some((h, f)); + break; } } }) @@ -427,11 +427,11 @@ async fn test_firehose_update_has_prev_field() { tungstenite::Message::Binary(bin) => bin, _ => continue, }; - if let Ok((_, f)) = parse_frame(&raw_bytes) { - if f.repo == did { - frame_opt = Some(f); - break; - } + if let Ok((_, f)) = parse_frame(&raw_bytes) + && f.repo == did + { + frame_opt = Some(f); + break; } } }) @@ -504,11 +504,11 @@ async fn test_firehose_commit_has_prev_data() { tungstenite::Message::Binary(bin) => bin, _ => continue, }; - if let Ok((_, f)) = parse_frame(&raw_bytes) { - if f.repo == did { - first_frame_opt = Some(f); - break; - } + if let Ok((_, f)) = parse_frame(&raw_bytes) + && f.repo == did + { + first_frame_opt = Some(f); + break; } } }) @@ -554,11 +554,11 @@ async fn test_firehose_commit_has_prev_data() { tungstenite::Message::Binary(bin) => bin, _ => continue, }; - if let Ok((_, f)) = parse_frame(&raw_bytes) { - if f.repo == did { - second_frame_opt = Some(f); - break; - } + if let Ok((_, f)) = parse_frame(&raw_bytes) + && f.repo == did + { + second_frame_opt = Some(f); + break; } } }) @@ -626,11 +626,11 @@ async fn test_compare_raw_cbor_encoding() { tungstenite::Message::Binary(bin) => bin, _ => continue, }; - if let Ok((_, f)) = parse_frame(&raw) { - if f.repo == did { - raw_bytes_opt = Some(raw.to_vec()); - break; - } + if let Ok((_, f)) = parse_frame(&raw) + && f.repo == did + { + raw_bytes_opt = Some(raw.to_vec()); + break; } } }) @@ -826,11 +826,11 @@ async fn test_firehose_outdated_cursor_info() { found_info = true; println!("Found OutdatedCursor info frame!"); } - } else if let Ok((_, frame)) = parse_frame(&bin) { - if frame.repo == did { - found_commit = true; - println!("Found commit for our DID"); - } + } else if let Ok((_, frame)) = parse_frame(&bin) + && frame.repo == did + { + found_commit = true; + println!("Found commit for our DID"); } if found_commit { break; diff --git a/tests/import_verification.rs b/tests/import_verification.rs index 56542fe..dd8c307 100644 --- a/tests/import_verification.rs +++ b/tests/import_verification.rs @@ -307,7 +307,7 @@ async fn test_import_preserves_records_after_reimport() { assert_eq!(res.status(), StatusCode::OK); let body: serde_json::Value = res.json().await.unwrap(); let uri = body["uri"].as_str().unwrap(); - let rkey = uri.split('/').last().unwrap().to_string(); + let rkey = uri.split('/').next_back().unwrap().to_string(); rkeys.push(rkey); } for rkey in &rkeys { diff --git a/tests/import_with_verification.rs b/tests/import_with_verification.rs index cc9a9bd..69b97ab 100644 --- a/tests/import_with_verification.rs +++ b/tests/import_with_verification.rs @@ -192,7 +192,7 @@ async fn test_import_with_valid_signature_and_mock_plc() { let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key"); let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_endpoint = format!("https://{}", hostname); - let handle = did.split(':').last().unwrap_or("user"); + let handle = did.split(':').next_back().unwrap_or("user"); let did_doc = create_did_document(&did, handle, &signing_key, &pds_endpoint); let mock_plc = setup_mock_plc_directory(&did, did_doc).await; unsafe { @@ -236,7 +236,7 @@ async fn test_import_with_wrong_signing_key_fails() { SigningKey::from_slice(&key_bytes).expect("Failed to create signing key"); let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_endpoint = format!("https://{}", hostname); - let handle = did.split(':').last().unwrap_or("user"); + let handle = did.split(':').next_back().unwrap_or("user"); let did_doc = create_did_document(&did, handle, &correct_signing_key, &pds_endpoint); let mock_plc = setup_mock_plc_directory(&did, did_doc).await; unsafe { @@ -285,7 +285,7 @@ async fn test_import_with_did_mismatch_fails() { let wrong_did = "did:plc:wrongdidthatdoesnotmatch"; let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_endpoint = format!("https://{}", hostname); - let handle = did.split(':').last().unwrap_or("user"); + let handle = did.split(':').next_back().unwrap_or("user"); let did_doc = create_did_document(&did, handle, &signing_key, &pds_endpoint); let mock_plc = setup_mock_plc_directory(&did, did_doc).await; unsafe { @@ -370,7 +370,7 @@ async fn test_import_with_no_signing_key_in_did_doc() { .await .expect("Failed to get user signing key"); let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key"); - let handle = did.split(':').last().unwrap_or("user"); + let handle = did.split(':').next_back().unwrap_or("user"); let did_doc_without_key = json!({ "@context": ["https://www.w3.org/ns/did/v1"], "id": did, diff --git a/tests/jwt_security.rs b/tests/jwt_security.rs index e20972a..1071a45 100644 --- a/tests/jwt_security.rs +++ b/tests/jwt_security.rs @@ -44,7 +44,7 @@ fn test_signature_attacks() { let token = create_access_token(did, &key_bytes).expect("create token"); let parts: Vec<&str> = token.split('.').collect(); - let forged_signature = URL_SAFE_NO_PAD.encode(&[0u8; 64]); + let forged_signature = URL_SAFE_NO_PAD.encode([0u8; 64]); let forged_token = format!("{}.{}.{}", parts[0], parts[1], forged_signature); let result = verify_access_token(&forged_token, &key_bytes); assert!(result.is_err(), "Forged signature must be rejected"); @@ -121,7 +121,7 @@ fn test_algorithm_substitution_attacks() { let mut mac = HmacSha256::new_from_slice(&key_bytes).unwrap(); mac.update(message.as_bytes()); let hmac_sig = mac.finalize().into_bytes(); - let hs256_token = format!("{}.{}", message, URL_SAFE_NO_PAD.encode(&hmac_sig)); + let hs256_token = format!("{}.{}", message, URL_SAFE_NO_PAD.encode(hmac_sig)); assert!( verify_access_token(&hs256_token, &key_bytes).is_err(), "HS256 substitution must be rejected" @@ -130,7 +130,7 @@ fn test_algorithm_substitution_attacks() { for (alg, sig_len) in [("RS256", 256), ("ES256", 64)] { let header = json!({ "alg": alg, "typ": TOKEN_TYPE_ACCESS }); let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); - let fake_sig = URL_SAFE_NO_PAD.encode(&vec![1u8; sig_len]); + let fake_sig = URL_SAFE_NO_PAD.encode(vec![1u8; sig_len]); let token = format!("{}.{}.{}", header_b64, claims_b64, fake_sig); assert!( verify_access_token(&token, &key_bytes).is_err(), @@ -335,7 +335,7 @@ fn test_malformed_tokens() { let invalid_header = URL_SAFE_NO_PAD.encode("{not valid json}"); let claims_b64 = URL_SAFE_NO_PAD.encode(r#"{"sub":"test"}"#); - let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 64]); + let fake_sig = URL_SAFE_NO_PAD.encode([1u8; 64]); assert!( verify_access_token( &format!("{}.{}.{}", invalid_header, claims_b64, fake_sig), @@ -439,7 +439,7 @@ fn test_did_and_jti_extraction() { let header_b64 = URL_SAFE_NO_PAD.encode(r#"{"alg":"ES256K"}"#); let claims_b64 = URL_SAFE_NO_PAD.encode(r#"{"iss":"did:plc:iss","sub":"did:plc:sub"}"#); - let fake_sig = URL_SAFE_NO_PAD.encode(&[0u8; 64]); + let fake_sig = URL_SAFE_NO_PAD.encode([0u8; 64]); let unverified = format!("{}.{}.{}", header_b64, claims_b64, fake_sig); assert_eq!(get_did_from_token(&unverified).unwrap(), "did:plc:sub"); @@ -479,7 +479,7 @@ fn test_header_injection_and_constant_time() { "{}.{}.{}", parts[0], parts[1], - URL_SAFE_NO_PAD.encode(&[0xFFu8; 64]) + URL_SAFE_NO_PAD.encode([0xFFu8; 64]) ); let _ = verify_access_token(&almost_valid_token, &key_bytes); let _ = verify_access_token(&completely_invalid_token, &key_bytes); diff --git a/tests/lifecycle_record.rs b/tests/lifecycle_record.rs index 3b8f2aa..93b4878 100644 --- a/tests/lifecycle_record.rs +++ b/tests/lifecycle_record.rs @@ -385,7 +385,7 @@ async fn test_authorization_protects_repos() { let (alice_did, alice_jwt) = setup_new_user("alice-auth").await; let (_bob_did, bob_jwt) = setup_new_user("bob-auth").await; let (post_uri, _) = create_post(&client, &alice_did, &alice_jwt, "Alice's post").await; - let post_rkey = post_uri.split('/').last().unwrap(); + let post_rkey = post_uri.split('/').next_back().unwrap(); let post_payload = json!({ "repo": alice_did, "collection": "app.bsky.feed.post", @@ -630,7 +630,7 @@ async fn test_list_records_comprehensive() { assert_eq!(records.len(), 5); let rkeys: Vec<&str> = records .iter() - .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()) + .map(|r| r["uri"].as_str().unwrap().split('/').next_back().unwrap()) .collect(); assert_eq!( rkeys, @@ -661,7 +661,7 @@ async fn test_list_records_comprehensive() { .as_array() .unwrap() .iter() - .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()) + .map(|r| r["uri"].as_str().unwrap().split('/').next_back().unwrap()) .collect(); assert_eq!( rev_rkeys, @@ -733,7 +733,7 @@ async fn test_list_records_comprehensive() { .as_array() .unwrap() .iter() - .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()) + .map(|r| r["uri"].as_str().unwrap().split('/').next_back().unwrap()) .collect(); for rkey in &range_rkeys { assert!( diff --git a/tests/lifecycle_session.rs b/tests/lifecycle_session.rs index 4379660..801ed7f 100644 --- a/tests/lifecycle_session.rs +++ b/tests/lifecycle_session.rs @@ -461,7 +461,7 @@ async fn test_account_deactivation_lifecycle() { let did = account["did"].as_str().unwrap().to_string(); let jwt = verify_new_account(&client, &did).await; let (post_uri, _) = create_post(&client, &did, &jwt, "Post before deactivation").await; - let post_rkey = post_uri.split('/').last().unwrap(); + let post_rkey = post_uri.split('/').next_back().unwrap(); let status_before = client .get(format!( "{}/xrpc/com.atproto.server.checkAccountStatus", diff --git a/tests/lifecycle_social.rs b/tests/lifecycle_social.rs index 8df2e4c..6e6381b 100644 --- a/tests/lifecycle_social.rs +++ b/tests/lifecycle_social.rs @@ -14,7 +14,7 @@ async fn test_like_lifecycle() { let (post_uri, post_cid) = create_post(&client, &alice_did, &alice_jwt, "Like this post!").await; let (like_uri, _) = create_like(&client, &bob_did, &bob_jwt, &post_uri, &post_cid).await; - let like_rkey = like_uri.split('/').last().unwrap(); + let like_rkey = like_uri.split('/').next_back().unwrap(); let get_like_res = client .get(format!( "{}/xrpc/com.atproto.repo.getRecord", @@ -74,7 +74,7 @@ async fn test_repost_lifecycle() { let (bob_did, bob_jwt) = setup_new_user("bob-repost").await; let (post_uri, post_cid) = create_post(&client, &alice_did, &alice_jwt, "Repost this!").await; let (repost_uri, _) = create_repost(&client, &bob_did, &bob_jwt, &post_uri, &post_cid).await; - let repost_rkey = repost_uri.split('/').last().unwrap(); + let repost_rkey = repost_uri.split('/').next_back().unwrap(); let get_repost_res = client .get(format!( "{}/xrpc/com.atproto.repo.getRecord", @@ -119,7 +119,7 @@ async fn test_unfollow_lifecycle() { let (alice_did, _alice_jwt) = setup_new_user("alice-unfollow").await; let (bob_did, bob_jwt) = setup_new_user("bob-unfollow").await; let (follow_uri, _) = create_follow(&client, &bob_did, &bob_jwt, &alice_did).await; - let follow_rkey = follow_uri.split('/').last().unwrap(); + let follow_rkey = follow_uri.split('/').next_back().unwrap(); let get_follow_res = client .get(format!( "{}/xrpc/com.atproto.repo.getRecord", @@ -240,7 +240,7 @@ async fn test_account_to_post_full_lifecycle() { .query(&[ ("repo", did.as_str()), ("collection", "app.bsky.feed.post"), - ("rkey", post_uri.split('/').last().unwrap()), + ("rkey", post_uri.split('/').next_back().unwrap()), ]) .send() .await diff --git a/tests/oauth.rs b/tests/oauth.rs index fb16da8..8ee264e 100644 --- a/tests/oauth.rs +++ b/tests/oauth.rs @@ -21,7 +21,7 @@ fn generate_pkce() -> (String, String) { let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); let mut hasher = Sha256::new(); hasher.update(code_verifier.as_bytes()); - let code_challenge = URL_SAFE_NO_PAD.encode(&hasher.finalize()); + let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize()); (code_verifier, code_challenge) } @@ -1036,7 +1036,7 @@ async fn test_granular_scope_repo_create_only() { ); let body: Value = create_res.json().await.unwrap(); let uri = body["uri"].as_str().expect("Should have uri"); - let rkey = uri.split('/').last().unwrap(); + let rkey = uri.split('/').next_back().unwrap(); let delete_res = http_client .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url)) .bearer_auth(&token) @@ -1092,7 +1092,7 @@ async fn test_granular_scope_wildcard_collection() { ); let body: Value = post_res.json().await.unwrap(); let uri = body["uri"].as_str().unwrap(); - let rkey = uri.split('/').last().unwrap(); + let rkey = uri.split('/').next_back().unwrap(); let delete_res = http_client .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url)) .bearer_auth(&token) diff --git a/tests/oauth_lifecycle.rs b/tests/oauth_lifecycle.rs index 1f153ee..5a41f04 100644 --- a/tests/oauth_lifecycle.rs +++ b/tests/oauth_lifecycle.rs @@ -17,7 +17,7 @@ fn generate_pkce() -> (String, String) { let mut hasher = Sha256::new(); hasher.update(code_verifier.as_bytes()); let hash = hasher.finalize(); - let code_challenge = URL_SAFE_NO_PAD.encode(&hash); + let code_challenge = URL_SAFE_NO_PAD.encode(hash); (code_verifier, code_challenge) } @@ -195,7 +195,7 @@ async fn test_oauth_token_can_create_and_read_records() { ); let create_body: Value = create_res.json().await.unwrap(); let uri = create_body["uri"].as_str().unwrap(); - let rkey = uri.split('/').last().unwrap(); + let rkey = uri.split('/').next_back().unwrap(); let get_res = http_client .get(format!("{}/xrpc/com.atproto.repo.getRecord", url)) .bearer_auth(&session.access_token) @@ -290,7 +290,7 @@ async fn test_oauth_full_post_lifecycle_create_edit_delete() { assert_eq!(create_res.status(), StatusCode::OK); let create_body: Value = create_res.json().await.unwrap(); let uri = create_body["uri"].as_str().unwrap(); - let rkey = uri.split('/').last().unwrap(); + let rkey = uri.split('/').next_back().unwrap(); let updated_text = "Updated post content via OAuth putRecord"; let put_res = http_client .post(format!("{}/xrpc/com.atproto.repo.putRecord", url)) diff --git a/tests/oauth_scopes.rs b/tests/oauth_scopes.rs index e1a0f18..5e4cac3 100644 --- a/tests/oauth_scopes.rs +++ b/tests/oauth_scopes.rs @@ -17,7 +17,7 @@ fn generate_pkce() -> (String, String) { let mut hasher = Sha256::new(); hasher.update(code_verifier.as_bytes()); let hash = hasher.finalize(); - let code_challenge = URL_SAFE_NO_PAD.encode(&hash); + let code_challenge = URL_SAFE_NO_PAD.encode(hash); (code_verifier, code_challenge) } @@ -215,7 +215,7 @@ async fn test_atproto_scope_allows_full_access() { .as_str() .unwrap() .split('/') - .last() + .next_back() .unwrap(); let put_res = http_client diff --git a/tests/oauth_security.rs b/tests/oauth_security.rs index 6758e80..ea2f68a 100644 --- a/tests/oauth_security.rs +++ b/tests/oauth_security.rs @@ -17,7 +17,7 @@ fn generate_pkce() -> (String, String) { let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); let mut hasher = Sha256::new(); hasher.update(code_verifier.as_bytes()); - let code_challenge = URL_SAFE_NO_PAD.encode(&hasher.finalize()); + let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize()); (code_verifier, code_challenge) } @@ -120,7 +120,7 @@ async fn test_token_tampering_attacks() { let (access_token, _, _) = get_oauth_tokens(&http_client, url).await; let parts: Vec<&str> = access_token.split('.').collect(); assert_eq!(parts.len(), 3); - let forged_sig = URL_SAFE_NO_PAD.encode(&[0u8; 32]); + let forged_sig = URL_SAFE_NO_PAD.encode([0u8; 32]); let forged_token = format!("{}.{}.{}", parts[0], parts[1], forged_sig); assert_eq!( http_client @@ -173,7 +173,7 @@ async fn test_token_tampering_attacks() { "{}.{}.{}", URL_SAFE_NO_PAD.encode(serde_json::to_string(&rs256_header).unwrap()), URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_payload).unwrap()), - URL_SAFE_NO_PAD.encode(&[1u8; 64]) + URL_SAFE_NO_PAD.encode([1u8; 64]) ); assert_eq!( http_client @@ -193,7 +193,7 @@ async fn test_token_tampering_attacks() { URL_SAFE_NO_PAD .encode(serde_json::to_string(&json!({"alg":"HS256","typ":"at+jwt"})).unwrap()), URL_SAFE_NO_PAD.encode(serde_json::to_string(&expired_payload).unwrap()), - URL_SAFE_NO_PAD.encode(&[1u8; 32]) + URL_SAFE_NO_PAD.encode([1u8; 32]) ); assert_eq!( http_client @@ -678,7 +678,7 @@ async fn test_malformed_tokens_and_headers() { "{}.{}.{}", URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()), URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()), - URL_SAFE_NO_PAD.encode(&[1u8; 32]) + URL_SAFE_NO_PAD.encode([1u8; 32]) ); assert_eq!( http_client diff --git a/tests/plc_migration.rs b/tests/plc_migration.rs index 362856f..570c572 100644 --- a/tests/plc_migration.rs +++ b/tests/plc_migration.rs @@ -727,7 +727,7 @@ async fn test_cross_pds_migration_with_records() { "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=app.bsky.feed.post&rkey={}", base_url().await, did, - original_uri.split('/').last().unwrap() + original_uri.split('/').next_back().unwrap() )) .send() .await @@ -970,7 +970,7 @@ async fn test_full_migration_flow_end_to_end() { .as_array() .expect("Should have records array"); assert!( - records.len() >= 1, + !records.is_empty(), "Should have at least 1 record after migration, found {}", records.len() ); diff --git a/tests/plc_operations.rs b/tests/plc_operations.rs index fbdb49c..ac58c76 100644 --- a/tests/plc_operations.rs +++ b/tests/plc_operations.rs @@ -114,7 +114,7 @@ async fn test_submit_plc_operation_validation() { .await .unwrap(); assert_eq!(res.status(), StatusCode::BAD_REQUEST); - let handle = did.split(':').last().unwrap_or("user"); + let handle = did.split(':').next_back().unwrap_or("user"); let res = client.post(format!("{}/xrpc/com.atproto.identity.submitPlcOperation", base_url().await)) .bearer_auth(&token).json(&json!({ "operation": { "type": "plc_operation", "rotationKeys": ["did:key:z123"], diff --git a/tests/plc_validation.rs b/tests/plc_validation.rs index c829d66..ee37640 100644 --- a/tests/plc_validation.rs +++ b/tests/plc_validation.rs @@ -172,7 +172,7 @@ fn test_signature_verification() { "verificationMethods": {}, "alsoKnownAs": [], "services": {}, "prev": null }); let signed = sign_operation(&op, &key).unwrap(); - let result = verify_operation_signature(&signed, &[did_key.clone()]); + let result = verify_operation_signature(&signed, std::slice::from_ref(&did_key)); assert!(result.is_ok() && result.unwrap()); let other_key = SigningKey::random(&mut rand::thread_rng()); diff --git a/tests/scope_edge_cases.rs b/tests/scope_edge_cases.rs new file mode 100644 index 0000000..2f77c2b --- /dev/null +++ b/tests/scope_edge_cases.rs @@ -0,0 +1,316 @@ +use tranquil_pds::delegation::{intersect_scopes, scopes::validate_delegation_scopes}; +use tranquil_pds::oauth::scopes::{ + AccountAction, IdentityAttr, ParsedScope, RepoAction, ScopePermissions, parse_scope, + parse_scope_string, +}; + +#[test] +fn test_repo_star_defaults_to_all_actions() { + let scope = parse_scope("repo:*"); + if let ParsedScope::Repo(repo) = scope { + assert!(repo.actions.contains(&RepoAction::Create)); + assert!(repo.actions.contains(&RepoAction::Update)); + assert!(repo.actions.contains(&RepoAction::Delete)); + assert_eq!(repo.actions.len(), 3); + } else { + panic!("Expected Repo scope"); + } +} + +#[test] +fn test_repo_collection_without_actions_defaults_to_all() { + let scope = parse_scope("repo:app.bsky.feed.post"); + if let ParsedScope::Repo(repo) = scope { + assert!(repo.actions.contains(&RepoAction::Create)); + assert!(repo.actions.contains(&RepoAction::Update)); + assert!(repo.actions.contains(&RepoAction::Delete)); + } else { + panic!("Expected Repo scope"); + } +} + +#[test] +fn test_repo_empty_string_after_colon() { + let scope = parse_scope("repo:"); + if let ParsedScope::Repo(repo) = scope { + assert!(repo.collection.is_none()); + } else { + panic!("Expected Repo scope"); + } +} + +#[test] +fn test_rpc_wildcard_aud_wildcard_forbidden() { + let scope = parse_scope("rpc:*?aud=*"); + assert!(matches!(scope, ParsedScope::Unknown(_))); +} + +#[test] +fn test_rpc_no_lxm_aud_wildcard_forbidden() { + let scope = parse_scope("rpc?aud=*"); + assert!(matches!(scope, ParsedScope::Unknown(_))); +} + +#[test] +fn test_rpc_specific_lxm_wildcard_aud_allowed() { + let scope = parse_scope("rpc:app.bsky.feed.getTimeline?aud=*"); + assert!(matches!(scope, ParsedScope::Rpc(_))); +} + +#[test] +fn test_rpc_wildcard_lxm_specific_aud_allowed() { + let scope = parse_scope("rpc:*?aud=did:web:api.bsky.app"); + assert!(matches!(scope, ParsedScope::Rpc(_))); +} + +#[test] +fn test_unknown_scope_preserved() { + let scope = parse_scope("completely:made:up:scope"); + if let ParsedScope::Unknown(s) = scope { + assert_eq!(s, "completely:made:up:scope"); + } else { + panic!("Expected Unknown scope"); + } +} + +#[test] +fn test_unknown_scope_with_params_preserved() { + let scope = parse_scope("unknown:thing?param=value"); + if let ParsedScope::Unknown(s) = scope { + assert_eq!(s, "unknown:thing?param=value"); + } else { + panic!("Expected Unknown scope"); + } +} + +#[test] +fn test_blob_empty_accept() { + let scope = parse_scope("blob"); + if let ParsedScope::Blob(blob) = scope { + assert!(blob.accept.is_empty()); + assert!(blob.matches_mime("anything/goes")); + } else { + panic!("Expected Blob scope"); + } +} + +#[test] +fn test_blob_matches_wildcard() { + let scope = parse_scope("blob:*/*"); + if let ParsedScope::Blob(blob) = scope { + assert!(blob.matches_mime("image/png")); + assert!(blob.matches_mime("video/mp4")); + assert!(blob.matches_mime("application/json")); + } else { + panic!("Expected Blob scope"); + } +} + +#[test] +fn test_blob_type_prefix_matching() { + let scope = parse_scope("blob:image/*"); + if let ParsedScope::Blob(blob) = scope { + assert!(blob.matches_mime("image/png")); + assert!(blob.matches_mime("image/jpeg")); + assert!(blob.matches_mime("image/gif")); + assert!(!blob.matches_mime("video/mp4")); + assert!(!blob.matches_mime("images/png")); + } else { + panic!("Expected Blob scope"); + } +} + +#[test] +fn test_account_default_action_is_read() { + let scope = parse_scope("account:email"); + if let ParsedScope::Account(a) = scope { + assert_eq!(a.action, AccountAction::Read); + } else { + panic!("Expected Account scope"); + } +} + +#[test] +fn test_multiple_scopes_parsing() { + let scopes = parse_scope_string("atproto repo:* blob:*/* transition:generic"); + assert_eq!(scopes.len(), 4); + assert!(matches!(scopes[0], ParsedScope::Atproto)); +} + +#[test] +fn test_permissions_null_scope_defaults_atproto() { + let perms = ScopePermissions::from_scope_string(None); + assert!(perms.has_full_access()); + assert!(perms.allows_repo(RepoAction::Create, "any.collection")); + assert!(perms.allows_repo(RepoAction::Update, "any.collection")); + assert!(perms.allows_repo(RepoAction::Delete, "any.collection")); +} + +#[test] +fn test_permissions_empty_string_defaults_atproto() { + let perms = ScopePermissions::from_scope_string(Some("")); + assert!(!perms.has_full_access()); +} + +#[test] +fn test_permissions_whitespace_only() { + let perms = ScopePermissions::from_scope_string(Some(" ")); + assert!(!perms.has_full_access()); +} + +#[test] +fn test_permissions_repo_collection_wildcard_prefix() { + let perms = ScopePermissions::from_scope_string(Some("repo:app.bsky.*?action=create")); + assert!(perms.allows_repo(RepoAction::Create, "app.bsky.feed.post")); + assert!(perms.allows_repo(RepoAction::Create, "app.bsky.actor.profile")); + assert!(!perms.allows_repo(RepoAction::Create, "com.atproto.repo.blob")); + assert!(!perms.allows_repo(RepoAction::Update, "app.bsky.feed.post")); +} + +#[test] +fn test_permissions_rpc_lxm_wildcard_prefix() { + let perms = + ScopePermissions::from_scope_string(Some("rpc:app.bsky.feed.*?aud=did:web:api.bsky.app")); + assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); + assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getAuthorFeed")); + assert!(!perms.allows_rpc("did:web:api.bsky.app", "app.bsky.actor.getProfile")); +} + +#[test] +fn test_delegation_intersect_params_behavior() { + let result = intersect_scopes("repo:*?action=create", "repo:*?action=delete"); + + assert!( + result.is_empty() || result.contains("repo:*"), + "Delegation intersection with different action params: '{}'", + result + ); +} + +#[test] +fn test_delegation_intersect_wildcard_vs_specific() { + let result = intersect_scopes("repo:app.bsky.feed.post?action=create", "repo:*"); + assert!(result.contains("repo:")); +} + +#[test] +fn test_delegation_validate_known_prefixes() { + assert!(validate_delegation_scopes("atproto").is_ok()); + assert!(validate_delegation_scopes("repo:*").is_ok()); + assert!(validate_delegation_scopes("blob:*/*").is_ok()); + assert!(validate_delegation_scopes("rpc:*").is_ok()); + assert!(validate_delegation_scopes("account:email").is_ok()); + assert!(validate_delegation_scopes("identity:handle").is_ok()); + assert!(validate_delegation_scopes("transition:generic").is_ok()); +} + +#[test] +fn test_delegation_validate_unknown_prefixes() { + assert!(validate_delegation_scopes("invalid:scope").is_err()); + assert!(validate_delegation_scopes("custom:something").is_err()); + assert!(validate_delegation_scopes("made:up").is_err()); +} + +#[test] +fn test_delegation_validate_empty() { + assert!(validate_delegation_scopes("").is_ok()); +} + +#[test] +fn test_delegation_validate_multiple() { + assert!(validate_delegation_scopes("atproto repo:* blob:*/*").is_ok()); + assert!(validate_delegation_scopes("atproto invalid:scope").is_err()); +} + +#[test] +fn test_delegation_intersect_empty_granted_returns_empty() { + assert_eq!(intersect_scopes("atproto", ""), ""); + assert_eq!(intersect_scopes("repo:*", ""), ""); +} + +#[test] +fn test_delegation_intersect_no_overlap() { + let result = intersect_scopes("repo:app.bsky.feed.post", "repo:com.atproto.something"); + assert!(result.is_empty()); +} + +#[test] +fn test_scope_with_multiple_params() { + let scope = parse_scope("repo:*?action=create&action=delete"); + if let ParsedScope::Repo(repo) = scope { + assert!(repo.actions.contains(&RepoAction::Create)); + assert!(repo.actions.contains(&RepoAction::Delete)); + assert!(!repo.actions.contains(&RepoAction::Update)); + } else { + panic!("Expected Repo scope"); + } +} + +#[test] +fn test_scope_invalid_action_ignored() { + let scope = parse_scope("repo:*?action=invalid"); + if let ParsedScope::Repo(repo) = scope { + assert!(repo.actions.contains(&RepoAction::Create)); + assert!(repo.actions.contains(&RepoAction::Update)); + assert!(repo.actions.contains(&RepoAction::Delete)); + } else { + panic!("Expected Repo scope"); + } +} + +#[test] +fn test_include_scope_parsing() { + let scope = parse_scope("include:app.bsky.authFullApp?aud=did:web:api.bsky.app"); + if let ParsedScope::Include(inc) = scope { + assert_eq!(inc.nsid, "app.bsky.authFullApp"); + assert_eq!(inc.aud, Some("did:web:api.bsky.app".to_string())); + } else { + panic!("Expected Include scope"); + } +} + +#[test] +fn test_include_scope_no_aud() { + let scope = parse_scope("include:com.example.authBasic"); + if let ParsedScope::Include(inc) = scope { + assert_eq!(inc.nsid, "com.example.authBasic"); + assert!(inc.aud.is_none()); + } else { + panic!("Expected Include scope"); + } +} + +#[test] +fn test_identity_wildcard_vs_specific() { + let wildcard = parse_scope("identity:*"); + let specific = parse_scope("identity:handle"); + + assert!(matches!(wildcard, ParsedScope::Identity(i) if i.attr == IdentityAttr::Wildcard)); + assert!(matches!(specific, ParsedScope::Identity(i) if i.attr == IdentityAttr::Handle)); +} + +#[test] +fn test_identity_unknown_attr() { + let scope = parse_scope("identity:unknown"); + assert!(matches!(scope, ParsedScope::Unknown(_))); +} + +#[test] +fn test_transition_scopes_exact_match() { + assert!(matches!( + parse_scope("transition:generic"), + ParsedScope::TransitionGeneric + )); + assert!(matches!( + parse_scope("transition:chat.bsky"), + ParsedScope::TransitionChat + )); + assert!(matches!( + parse_scope("transition:email"), + ParsedScope::TransitionEmail + )); + assert!(matches!( + parse_scope("transition:unknown"), + ParsedScope::Unknown(_) + )); +} diff --git a/tests/sync_repo.rs b/tests/sync_repo.rs index 9e2167f..814f092 100644 --- a/tests/sync_repo.rs +++ b/tests/sync_repo.rs @@ -115,7 +115,7 @@ async fn test_list_repos_pagination() { let mut page_count = 0; let max_pages = 100; loop { - let mut params: Vec<(&str, String)> = vec![("limit".into(), "10".into())]; + let mut params: Vec<(&str, String)> = vec![("limit", "10".into())]; if let Some(ref c) = cursor { params.push(("cursor", c.clone())); } @@ -313,7 +313,7 @@ async fn test_get_record_sync_success() { .expect("Failed to create record"); let create_body: Value = create_res.json().await.expect("Invalid JSON"); let uri = create_body["uri"].as_str().expect("No URI"); - let rkey = uri.split('/').last().expect("Invalid URI"); + let rkey = uri.split('/').next_back().expect("Invalid URI"); let params = [ ("did", did.as_str()), ("collection", "app.bsky.feed.post"), @@ -418,7 +418,7 @@ async fn test_sync_record_lifecycle() { let client = client(); let (did, jwt) = setup_new_user("sync-record-lifecycle").await; let (post_uri, _post_cid) = create_post(&client, &did, &jwt, "Post for sync record test").await; - let post_rkey = post_uri.split('/').last().unwrap(); + let post_rkey = post_uri.split('/').next_back().unwrap(); let sync_record_res = client .get(format!( "{}/xrpc/com.atproto.sync.getRecord", @@ -503,7 +503,7 @@ async fn test_sync_record_lifecycle() { StatusCode::NOT_FOUND, "Deleted record should return 404 via sync.getRecord" ); - let post2_rkey = post2_uri.split('/').last().unwrap(); + let post2_rkey = post2_uri.split('/').next_back().unwrap(); let sync_post2_res = client .get(format!( "{}/xrpc/com.atproto.sync.getRecord", diff --git a/tests/validation_edge_cases.rs b/tests/validation_edge_cases.rs new file mode 100644 index 0000000..297328e --- /dev/null +++ b/tests/validation_edge_cases.rs @@ -0,0 +1,343 @@ +use tranquil_pds::api::validation::{ + HandleValidationError, MAX_DOMAIN_LABEL_LENGTH, MAX_EMAIL_LENGTH, MAX_LOCAL_PART_LENGTH, + MAX_SERVICE_HANDLE_LOCAL_PART, is_valid_email, validate_short_handle, +}; +use tranquil_pds::validation::{ + is_valid_did, validate_collection_nsid, validate_password, validate_record_key, +}; + +#[test] +fn test_record_key_boundary_min() { + assert!(validate_record_key("a").is_ok()); + assert!(validate_record_key("1").is_ok()); + assert!(validate_record_key("-").is_ok()); + assert!(validate_record_key("_").is_ok()); + assert!(validate_record_key("~").is_ok()); +} + +#[test] +fn test_record_key_boundary_max() { + assert!(validate_record_key(&"a".repeat(512)).is_ok()); + assert!(validate_record_key(&"a".repeat(513)).is_err()); + assert!(validate_record_key(&"a".repeat(1000)).is_err()); +} + +#[test] +fn test_record_key_special_dot_cases() { + assert!(validate_record_key(".").is_err()); + assert!(validate_record_key("..").is_err()); + assert!(validate_record_key("...").is_ok()); + assert!(validate_record_key("a.b").is_ok()); + assert!(validate_record_key(".a").is_ok()); + assert!(validate_record_key("a.").is_ok()); + assert!(validate_record_key("a..b").is_ok()); +} + +#[test] +fn test_record_key_all_valid_chars() { + assert!(validate_record_key("abc").is_ok()); + assert!(validate_record_key("ABC").is_ok()); + assert!(validate_record_key("123").is_ok()); + assert!(validate_record_key("a-b").is_ok()); + assert!(validate_record_key("a_b").is_ok()); + assert!(validate_record_key("a~b").is_ok()); + assert!(validate_record_key("a.b").is_ok()); + assert!(validate_record_key("aA1-_.~").is_ok()); +} + +#[test] +fn test_record_key_invalid_chars() { + assert!(validate_record_key("a/b").is_err()); + assert!(validate_record_key("a\\b").is_err()); + assert!(validate_record_key("a b").is_err()); + assert!(validate_record_key("a@b").is_err()); + assert!(validate_record_key("a#b").is_err()); + assert!(validate_record_key("a$b").is_err()); + assert!(validate_record_key("a%b").is_err()); + assert!(validate_record_key("a&b").is_err()); + assert!(validate_record_key("a*b").is_err()); + assert!(validate_record_key("a+b").is_err()); + assert!(validate_record_key("a=b").is_err()); + assert!(validate_record_key("a?b").is_err()); + assert!(validate_record_key("a:b").is_err()); + assert!(validate_record_key("a;b").is_err()); + assert!(validate_record_key("ab").is_err()); + assert!(validate_record_key("a[b").is_err()); + assert!(validate_record_key("a]b").is_err()); + assert!(validate_record_key("a{b").is_err()); + assert!(validate_record_key("a}b").is_err()); + assert!(validate_record_key("a|b").is_err()); + assert!(validate_record_key("a`b").is_err()); + assert!(validate_record_key("a'b").is_err()); + assert!(validate_record_key("a\"b").is_err()); + assert!(validate_record_key("a\nb").is_err()); + assert!(validate_record_key("a\tb").is_err()); + assert!(validate_record_key("a\rb").is_err()); + assert!(validate_record_key("a\0b").is_err()); +} + +#[test] +fn test_record_key_unicode() { + assert!(validate_record_key("café").is_err()); + assert!(validate_record_key("日本語").is_err()); + assert!(validate_record_key("emoji😀").is_err()); +} + +#[test] +fn test_password_length_boundaries() { + let base_valid = "Aa1"; + + let pass_7 = format!("{}{}", base_valid, "x".repeat(4)); + assert!(validate_password(&pass_7).is_err()); + + let pass_8 = format!("{}{}", base_valid, "x".repeat(5)); + assert!(validate_password(&pass_8).is_ok()); + + let pass_256 = format!("{}{}", base_valid, "x".repeat(253)); + assert!(validate_password(&pass_256).is_ok()); + + let pass_257 = format!("{}{}", base_valid, "x".repeat(254)); + assert!(validate_password(&pass_257).is_err()); +} + +#[test] +fn test_password_missing_requirements() { + assert!(validate_password("abcdefgh").is_err()); + assert!(validate_password("ABCDEFGH").is_err()); + assert!(validate_password("12345678").is_err()); + + assert!(validate_password("abcd1234").is_err()); + assert!(validate_password("ABCD1234").is_err()); + assert!(validate_password("abcdABCD").is_err()); + + assert!(validate_password("aB1xxxxx").is_ok()); +} + +#[test] +fn test_password_common_passwords() { + assert!(validate_password("Password1").is_err()); + assert!(validate_password("PASSWORD1").is_err()); + assert!(validate_password("password1").is_err()); + assert!(validate_password("Qwerty123").is_err()); + assert!(validate_password("Bluesky123").is_err()); +} + +#[test] +fn test_password_special_chars_allowed() { + assert!(validate_password("Aa1!@#$%").is_ok()); + assert!(validate_password("Aa1^&*()").is_ok()); + assert!(validate_password("Aa1 space").is_ok()); +} + +#[test] +fn test_did_validation_basic() { + assert!(is_valid_did("did:plc:abc123")); + assert!(is_valid_did("did:web:example.com")); + assert!(is_valid_did( + "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + )); +} + +#[test] +fn test_did_validation_invalid() { + assert!(!is_valid_did("")); + assert!(!is_valid_did("did")); + assert!(!is_valid_did("did:")); + assert!(!is_valid_did("did::")); + assert!(!is_valid_did("did:plc")); + assert!(!is_valid_did("did:plc:")); + assert!(!is_valid_did(":plc:abc")); + assert!(!is_valid_did("plc:abc")); +} + +#[test] +fn test_did_validation_method_case() { + assert!(!is_valid_did("did:PLC:abc123")); + assert!(!is_valid_did("did:Plc:abc123")); + assert!(!is_valid_did("DID:plc:abc123")); +} + +#[test] +fn test_did_validation_method_chars() { + assert!(!is_valid_did("did:plc1:abc")); + assert!(!is_valid_did("did:plc-x:abc")); + assert!(!is_valid_did("did:plc_x:abc")); +} + +#[test] +fn test_collection_nsid_minimum_segments() { + assert!(validate_collection_nsid("a.b.c").is_ok()); + assert!(validate_collection_nsid("a.b").is_err()); + assert!(validate_collection_nsid("a").is_err()); + assert!(validate_collection_nsid("").is_err()); +} + +#[test] +fn test_collection_nsid_many_segments() { + assert!(validate_collection_nsid("a.b.c.d.e.f.g.h.i.j").is_ok()); +} + +#[test] +fn test_collection_nsid_empty_segments() { + assert!(validate_collection_nsid("a..b.c").is_err()); + assert!(validate_collection_nsid(".a.b.c").is_err()); + assert!(validate_collection_nsid("a.b.c.").is_err()); + assert!(validate_collection_nsid("a.b..c").is_err()); +} + +#[test] +fn test_collection_nsid_valid_chars() { + assert!(validate_collection_nsid("app.bsky.feed.post").is_ok()); + assert!(validate_collection_nsid("com.example.my-record").is_ok()); + assert!(validate_collection_nsid("app.example.record123").is_ok()); + assert!(validate_collection_nsid("APP.BSKY.FEED.POST").is_ok()); +} + +#[test] +fn test_collection_nsid_invalid_chars() { + assert!(validate_collection_nsid("app.bsky.feed_post").is_err()); + assert!(validate_collection_nsid("app.bsky.feed/post").is_err()); + assert!(validate_collection_nsid("app.bsky.feed:post").is_err()); + assert!(validate_collection_nsid("app.bsky.feed@post").is_err()); +} + +#[test] +fn test_handle_boundary_lengths() { + let min_handle = "abc"; + assert!(validate_short_handle(min_handle).is_ok()); + + let under_min = "ab"; + assert!(matches!( + validate_short_handle(under_min), + Err(HandleValidationError::TooShort) + )); + + let at_max = "a".repeat(MAX_SERVICE_HANDLE_LOCAL_PART); + assert!(validate_short_handle(&at_max).is_ok()); + + let over_max = "a".repeat(MAX_SERVICE_HANDLE_LOCAL_PART + 1); + assert!(matches!( + validate_short_handle(&over_max), + Err(HandleValidationError::TooLong) + )); +} + +#[test] +fn test_handle_hyphen_positions() { + assert!(validate_short_handle("a-b-c").is_ok()); + assert!(validate_short_handle("a--b").is_ok()); + assert!(validate_short_handle("---").is_err()); + assert!(matches!( + validate_short_handle("-abc"), + Err(HandleValidationError::StartsWithInvalidChar) + )); + assert!(matches!( + validate_short_handle("abc-"), + Err(HandleValidationError::EndsWithInvalidChar) + )); +} + +#[test] +fn test_handle_case_normalization() { + assert_eq!(validate_short_handle("ABC").unwrap(), "abc"); + assert_eq!(validate_short_handle("AbC123").unwrap(), "abc123"); + assert_eq!(validate_short_handle("MixedCase").unwrap(), "mixedcase"); +} + +#[test] +fn test_handle_whitespace_handling() { + assert_eq!(validate_short_handle(" abc ").unwrap(), "abc"); + assert!(matches!( + validate_short_handle("a b c"), + Err(HandleValidationError::ContainsSpaces) + )); + assert!(matches!( + validate_short_handle("a\tb"), + Err(HandleValidationError::ContainsSpaces) + )); + assert!(matches!( + validate_short_handle("a\nb"), + Err(HandleValidationError::ContainsSpaces) + )); +} + +#[test] +fn test_email_length_boundaries() { + let long_local = format!("{}@example.com", "a".repeat(MAX_LOCAL_PART_LENGTH)); + assert!(is_valid_email(&long_local)); + + let too_long_local = format!("{}@example.com", "a".repeat(MAX_LOCAL_PART_LENGTH + 1)); + assert!(!is_valid_email(&too_long_local)); + + let very_long_email = format!("a@{}.com", "a".repeat(240)); + if very_long_email.len() <= MAX_EMAIL_LENGTH { + assert!(is_valid_email(&very_long_email) || !is_valid_email(&very_long_email)); + } +} + +#[test] +fn test_email_local_part_special_chars() { + assert!(is_valid_email("user.name@example.com")); + assert!(is_valid_email("user+tag@example.com")); + assert!(is_valid_email("user!def@example.com")); + assert!(is_valid_email("user#abc@example.com")); + assert!(is_valid_email("user$def@example.com")); + assert!(is_valid_email("user%abc@example.com")); + assert!(is_valid_email("user&def@example.com")); + assert!(is_valid_email("user'abc@example.com")); + assert!(is_valid_email("user*def@example.com")); + assert!(is_valid_email("user=abc@example.com")); + assert!(is_valid_email("user?def@example.com")); + assert!(is_valid_email("user^abc@example.com")); + assert!(is_valid_email("user_def@example.com")); + assert!(is_valid_email("user`abc@example.com")); + assert!(is_valid_email("user{def@example.com")); + assert!(is_valid_email("user|abc@example.com")); + assert!(is_valid_email("user}def@example.com")); + assert!(is_valid_email("user~abc@example.com")); + assert!(is_valid_email("user-def@example.com")); +} + +#[test] +fn test_email_local_part_dots() { + assert!(!is_valid_email(".user@example.com")); + assert!(!is_valid_email("user.@example.com")); + assert!(!is_valid_email("user..name@example.com")); + assert!(is_valid_email("user.name@example.com")); + assert!(is_valid_email("u.s.e.r@example.com")); +} + +#[test] +fn test_email_domain_labels() { + let long_label = "a".repeat(MAX_DOMAIN_LABEL_LENGTH); + let valid_domain = format!("user@{}.com", long_label); + assert!(is_valid_email(&valid_domain)); + + let too_long_label = "a".repeat(MAX_DOMAIN_LABEL_LENGTH + 1); + let invalid_domain = format!("user@{}.com", too_long_label); + assert!(!is_valid_email(&invalid_domain)); +} + +#[test] +fn test_email_domain_hyphens() { + assert!(!is_valid_email("user@-example.com")); + assert!(!is_valid_email("user@example-.com")); + assert!(is_valid_email("user@ex-ample.com")); + assert!(is_valid_email("user@ex--ample.com")); +} + +#[test] +fn test_email_domain_must_have_dot() { + assert!(!is_valid_email("user@localhost")); + assert!(!is_valid_email("user@example")); + assert!(is_valid_email("user@a.b")); +} + +#[test] +fn test_email_invalid_chars() { + assert!(!is_valid_email("user name@example.com")); + assert!(!is_valid_email("user\t@example.com")); + assert!(!is_valid_email("user\n@example.com")); + assert!(!is_valid_email("user@exam ple.com")); +} diff --git a/tests/verify_live_commit.rs b/tests/verify_live_commit.rs index aee0589..e96e82b 100644 --- a/tests/verify_live_commit.rs +++ b/tests/verify_live_commit.rs @@ -84,6 +84,7 @@ fn commit_unsigned_bytes(commit: &jacquard_repo::commit::Commit<'_>) -> Vec serde_ipld_dagcbor::to_vec(&unsigned).unwrap() } +#[allow(clippy::type_complexity)] fn parse_car( cursor: &mut std::io::Cursor<&[u8]>, ) -> Result<(Vec, HashMap), Box> { @@ -113,11 +114,8 @@ fn parse_car( } let header: CarHeader = serde_ipld_dagcbor::from_slice(&header_bytes)?; let mut blocks = HashMap::new(); - loop { - let block_len = match read_varint(cursor) { - Ok(len) => len as usize, - Err(_) => break, - }; + while let Ok(len) = read_varint(cursor) { + let block_len = len as usize; if block_len == 0 { break; }