mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-16 06:16:06 +00:00
Performance improvements
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
+22
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
+54
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+108
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+60
@@ -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"
|
||||
}
|
||||
+58
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
-16
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
+125
-31
@@ -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<String> = 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<uuid::Uuid> = 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<String> = 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<uuid::Uuid, String> = 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<String, Vec<InviteCodeUseInfo>> =
|
||||
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<uuid::Uuid, Vec<InviteCodeInfo>> =
|
||||
std::collections::HashMap::new();
|
||||
let mut code_info_map: std::collections::HashMap<String, InviteCodeInfo> =
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+23
-13
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<PlcOpOrTombstone, CircuitBreakerError<PlcError>> =
|
||||
with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
|
||||
|
||||
@@ -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<PlcError>> =
|
||||
|
||||
@@ -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<Client> = OnceLock::new();
|
||||
static DID_RESOLUTION_CLIENT: OnceLock<Client> = OnceLock::new();
|
||||
static HANDLE_RESOLUTION_CLIENT: OnceLock<Client> = 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();
|
||||
|
||||
+74
-40
@@ -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<AppState>,
|
||||
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<Box<dyn futures::Stream<Item = Result<Bytes, std::io::Error>> + 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 {
|
||||
|
||||
@@ -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<AppState>,
|
||||
|
||||
@@ -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<dyn Cache>, 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<dyn Cache>,
|
||||
did: &str,
|
||||
with_retry: bool,
|
||||
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
|
||||
@@ -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 {:?})",
|
||||
|
||||
@@ -158,12 +158,21 @@ pub async fn create_app_password(
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.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();
|
||||
|
||||
@@ -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
|
||||
|
||||
+42
-22
@@ -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)
|
||||
|
||||
+84
-14
@@ -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<CachedUserStatus> = 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<dyn Cache>, 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(
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
+1
-7
@@ -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<String, HandleResolution
|
||||
|
||||
pub async fn resolve_handle_http(handle: &str) -> Result<String, HandleResolutionError> {
|
||||
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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
+57
-5
@@ -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<Arc<dyn Cache>>,
|
||||
}
|
||||
|
||||
impl PlcClient {
|
||||
pub fn new(base_url: Option<String>) -> Self {
|
||||
Self::with_cache(base_url, None)
|
||||
}
|
||||
|
||||
pub fn with_cache(base_url: Option<String>, cache: Option<Arc<dyn Cache>>) -> 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<Value, PlcError> {
|
||||
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<Value, PlcError> {
|
||||
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<PlcOpOrTombstone, PlcError> {
|
||||
|
||||
+30
-25
@@ -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<String> = Vec::new();
|
||||
let mut batch_blob_cids: Vec<String> = 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;
|
||||
}
|
||||
|
||||
@@ -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<Vec<u8>, StorageError>;
|
||||
async fn get_bytes(&self, key: &str) -> Result<Bytes, StorageError>;
|
||||
async fn delete(&self, key: &str) -> Result<(), StorageError>;
|
||||
async fn put_stream(
|
||||
&self,
|
||||
key: &str,
|
||||
stream: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
|
||||
) -> Result<StreamUploadResult, StorageError>;
|
||||
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<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
|
||||
) -> Result<StreamUploadResult, StorageError> {
|
||||
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<CompletedPart> = 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<u8>|
|
||||
-> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<CompletedPart, StorageError>> + 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(())
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -216,7 +216,7 @@ fn format_identity_event(event: &SequencedEvent) -> Result<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>,
|
||||
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)
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
+285
-288
@@ -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<Cid, Bytes> = 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<Cid, Bytes> = 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(_)));
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ async fn test_check_account_status_returns_correct_block_count() {
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.split('/')
|
||||
.last()
|
||||
.next_back()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
|
||||
+1
-1
@@ -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]
|
||||
|
||||
+23
-21
@@ -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());
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(_)
|
||||
));
|
||||
}
|
||||
+4
-4
@@ -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",
|
||||
|
||||
@@ -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("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\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"));
|
||||
}
|
||||
@@ -84,6 +84,7 @@ fn commit_unsigned_bytes(commit: &jacquard_repo::commit::Commit<'_>) -> Vec<u8>
|
||||
serde_ipld_dagcbor::to_vec(&unsigned).unwrap()
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn parse_car(
|
||||
cursor: &mut std::io::Cursor<&[u8]>,
|
||||
) -> Result<(Vec<Cid>, HashMap<Cid, Bytes>), Box<dyn std::error::Error>> {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user