Compare commits

..
Author SHA1 Message Date
nelind bfadc936c0 fix(api): dont verify signature or DID during importRepo 2026-06-11 23:23:37 +02:00
nelind 80afd764d3 fix(nix): provide a jemalloc build in the dev shell 2026-06-10 19:56:40 +02:00
LewisandTangled 5bbe2146ff server: serve xrpc over http/3
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-10 13:18:49 +03:00
LewisandTangled b009ccdaf2 repo: the pg side of MST structural repair
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-08 16:58:51 +03:00
nelind 39f74b5adf feat(nix): pratice what i preach. expose lib.mkPackages 2026-06-07 22:14:27 +02:00
aveandTangled a231d7da29 feat(auth): accept totp/backup codes in legacy login createSession 2026-06-06 10:42:24 +03:00
aveandTangled 63d84d38fb refactor(auth): unify short-code generation onto util::generate_token_code
Collapse the three ad-hoc short-code generators into one canonical generator
plus a shared normalizer:

- util::generate_token_code now emits the uppercase base32 XXXXX-XXXXX display
  form; new util::normalize_token_code canonicalizes user input (uppercase,
  strip hyphen/whitespace).
- email_token and legacy_2fa now generate via util, store the normalized form,
  and compare normalized input. Their private generate_short_token/generate_code
  (and BASE32_CHARS/CODE_LENGTH) are removed.
- PLC (request/sign) and password reset inline util::generate_token_code,
  persist the normalized form, email the display form, and normalize input
  before lookup. The generate_plc_token/generate_reset_code wrappers are removed.

Behavior changes: legacy login-2FA codes go from 8-digit numeric to XXXXX-XXXXX;
PLC and password-reset codes go from lowercase to uppercase. All four code types
are now accepted case-/hyphen-insensitively. OAuth web-login 2FA, account
deletion, and the long verification_token blobs are intentionally untouched.

Tests: add util normalize tests + email_token/legacy_2fa case/hyphen tests;
update integration tests to expect the canonical stored form and the new
emailed format.
2026-06-06 09:36:08 +03:00
aveandTangled fe9b88141c chore(auth): align at+jwt/refresh+jwt expiry with reference PDS 2026-06-06 01:43:46 +03:00
aveandTangled 72f5dce32b add ave.zone to contributors list in README 2026-06-06 01:42:54 +03:00
nelind cd7e01100e chore(auth): also mention that atproto spec requiers typ be "JWT" for inter-service tokens 2026-06-05 12:49:25 +02:00
aveandTangled 7c248be153 fix(auth): emit uppercase "JWT" typ in service-auth header
RFC 7519 §5.1 recommends the uppercase "JWT" typ for compatibility with
legacy implementations, and it matches the reference @atproto/pds. Parsing
already lowercases, so existing lowercase "jwt" tokens still verify.
2026-06-05 13:34:43 +03:00
aveandTangled 91999819c6 fix(proxy): limit audience of getFeed service-auth to the feed generator 2026-06-04 22:39:14 +03:00
nelindandTangled ffce1d5d05 feat(docs): add some general PDS debugging documentation 2026-06-04 13:45:08 +03:00
TylerandTangled 8e6ace2fe2 fix: derive lexicon DNS authority from all-but-last NSID segment
Permission-set expansion resolved the lexicon's DNS authority using a
fixed `parts[..2]`, which only works for three-segment NSIDs. For a
four-segment NSID such as community.lexicon.bookmarks.authManageBookmarks
this dropped a segment and queried _lexicon.lexicon.community instead of
_lexicon.bookmarks.lexicon.community, failing with "DNS resolution
failed: ... no record found".

The authority is every NSID segment except the last (the name),
reversed. Use parts[..parts.len() - 1] to match the spec and the
existing extract_namespace_authority helper, and update the DNS
authority test with three/four-segment and bookmarks regression cases.
2026-06-04 01:28:13 +03:00
LewisandTangled 3018a20843 fix(plc): allow arbitrary services to sign
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-03 11:23:51 +03:00
LewisandTangled 37fc06fb39 fix(store): unblock eventlog sync&freeze when writer dies
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-02 17:29:21 +03:00
LewisandTangled 728a8c4d3b test(store): cross-store, firehose, read-validation coverage w/ faults
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-02 17:29:21 +03:00
LewisandTangled 3d49e99cc3 test(store): generic consistency checker, gauntlet fault/read
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-02 17:29:21 +03:00
LewisandTangled 7e823673ca test(store): untested metastore, eventlog, & archival stuff
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-02 17:29:21 +03:00
Lewis 320933598c fix(auth): error num 401 for oauth
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-01 19:38:45 +03:00
Lewis 500dc2e0e6 test(store): gauntlet sweep configs for time-travel & fsync repro
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-01 19:01:04 +03:00
Lewis a220611a8b test(store): D gauntlet faults, crash-loss oracley, recoverable scenarios
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-01 19:01:04 +03:00
Lewis ca7a4b4b73 fix(store): recover eventlog lastseq from tail not sidecar
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-01 19:01:04 +03:00
Lewis 8ff02610e4 feat(store): inline-commit mode, committed-extent recovery, failsafe verify&rollback
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-01 19:01:04 +03:00
LewisandTangled 22f82489d5 test(pds): e2e & durability coverage for MST self-heal
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-31 21:11:36 +03:00
LewisandTangled cee483e358 feat(pds): selfhealing repo writing by detecting corruption & retrying
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-31 21:11:36 +03:00
LewisandTangled 7f8e858137 test(store): gauntlet MST-repairable & misdirected-write scenario
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-31 21:11:36 +03:00
LewisandTangled 44d73dac58 feat(store): rebuild & rewrite missing/corrupt MST blocks
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-31 21:11:36 +03:00
LewisandTangled b8cae15c12 feat(store): detect foreign&corrupt blocks on read & preserve blocks thru recovery
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-31 21:11:36 +03:00
LewisandTangled 9b58961bba feat(repo): missing $type? invent one
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-31 13:02:11 +03:00
Lewis 31ee12ecd3 fix(store): torn hint-file tail should be recoverable on reopen
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-31 11:37:21 +03:00
nelind ea106d5246 chore(nix): update frontend pnpm hash 2026-05-31 00:51:27 +02:00
Lewis 4015217a2e feat(store): Clock trait for DST
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-30 23:46:22 +03:00
Lewis 7e4c9e1dab chore: bump to 0.6.4
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-30 21:23:02 +03:00
LewisandTangled e9dc57d6f4 fix(firehose): lost events if seq commits out of order
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-30 21:10:38 +03:00
Lewis 4e2525b245 fix(migrate): oauth account:* spec, p256 support
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-30 21:07:16 +03:00
nelind 38508c2c6e feat(nix): base the data storage path defaults on the dataDir option 2026-05-30 00:37:55 +02:00
Lewis 7a54ccf6a3 fix(tranquil-config): reject unknown keys in TlsConfig, bump rust 1.96
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-29 09:39:14 +03:00
169 changed files with 9175 additions and 1485 deletions
+20
View File
@@ -106,6 +106,16 @@ slow-timeout = { period = "300s", terminate-after = 8 }
filter = "binary(compaction_restart) | binary(mst_refcount_integrity) | binary(gc_compaction_restart)"
slow-timeout = { period = "120s", terminate-after = 4 }
[[profile.default.overrides]]
filter = "test(/retention_time_travel_survives_many_seeds/)"
slow-timeout = { period = "300s", terminate-after = 24 }
test-group = "io-heavy-sim"
[[profile.default.overrides]]
filter = "binary(fd_lifecycle)"
slow-timeout = { period = "300s", terminate-after = 4 }
test-group = "io-heavy-sim"
[[profile.ci.overrides]]
filter = "test(/import_with_verification/) | test(/plc_migration/)"
test-group = "serial-env-tests"
@@ -137,3 +147,13 @@ test-group = "heavy-load-tests"
[[profile.ci.overrides]]
filter = "binary(repo_lifecycle)"
test-group = "heavy-load-tests"
[[profile.ci.overrides]]
filter = "test(/retention_time_travel_survives_many_seeds/)"
slow-timeout = { period = "300s", terminate-after = 24 }
test-group = "io-heavy-sim"
[[profile.ci.overrides]]
filter = "binary(fd_lifecycle)"
slow-timeout = { period = "300s", terminate-after = 4 }
test-group = "io-heavy-sim"
+12
View File
@@ -0,0 +1,12 @@
target/
.git/
.jj/
**/node_modules/
frontend/dist/
frontend/coverage/
frontend/.pnpm-store/
.direnv/
result
.env
*.output
reference-pds-bsky/
+1
View File
@@ -4,5 +4,6 @@ target/
result
frontend/node_modules/
frontend/dist/
frontend/coverage/
frontend/.pnpm-store
frontend/.npmrc
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT nextval('firehose_seq') as \"seq!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "09142b5a6b91fe4fd795fb6c85af5461e2621f6c3545b6d26e4618d813904b75"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE repo_seq r\n SET seq = $1::bigint + (o.ord - 1)\n FROM unnest($2::bigint[]) WITH ORDINALITY AS o(id, ord)\n WHERE r.id = o.id",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8Array"
]
},
"nullable": []
},
"hash": "0b895d105363a60040323c5c47bc897905af3da980d0dc1b3b60bbdcdd90ea7a"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM repo_seq\n WHERE did = $1\n AND id <> (SELECT id FROM repo_seq WHERE did = $1 ORDER BY id DESC LIMIT 1)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "122d79e006c0e11214e3cf11cfb98bdff6fbf156893b482572532ebcd8979e61"
}
@@ -1,11 +1,11 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, active, status)\n VALUES ($1, 'account', false, 'deleted')\n RETURNING seq\n ",
"query": "\n INSERT INTO repo_seq (did, event_type, active, status)\n VALUES ($1, 'account', false, 'deleted')\n RETURNING id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq",
"name": "id",
"type_info": "Int8"
}
],
@@ -18,5 +18,5 @@
false
]
},
"hash": "a0ea3630adb9e411d94d88084df6b05d7fc3ba8c306146fc932b736fe342eceb"
"hash": "2c3c0740793f29b9bd9e0f81c2372d427dee091e20da8f71ae1e5f2769aa985d"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT pg_try_advisory_xact_lock($1) as \"locked!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "locked!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "3cc7247d2cd7e85a6f21d783dd956f8fecc6f63c994014bb4e91504d498b5fd5"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, handle)\n VALUES ($1, 'identity', $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "4f3ef746cb5fc0916d3df5f92494c538497d3b9c1cfcc6e44c9b80f2193094f6"
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, handle)\n VALUES ($1, 'identity', $2)\n RETURNING seq\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "50b2178cd174af1dc132a22048e52a6af492f4d8025ec512dd9c0c9ba62eb202"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id as \"id!\" FROM repo_seq WHERE seq IS NULL ORDER BY id LIMIT $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "714a1c9a73e2fed8182d4ab2fe4a2c33bc10a52e27ed95d3a6641477ae42395e"
}
@@ -1,11 +1,11 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1",
"query": "SELECT seq as \"seq!\", did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq",
"name": "seq!",
"type_info": "Int8"
},
{
@@ -90,7 +90,7 @@
]
},
"nullable": [
false,
true,
false,
false,
false,
@@ -108,5 +108,5 @@
true
]
},
"hash": "4702a94384ce938d3f5384bc2106a48500f8749134e6bfd1c619a9151f5d3772"
"hash": "7cdf3b8136782a5b2e4aff91907a680566b4610e6cbf9d9396d31f9bbd99c3b8"
}
@@ -1,14 +1,8 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, rev)\n VALUES ($1, 'commit', $2, $3::TEXT, $4, $5, $6, $7, $8)\n RETURNING seq\n ",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, rev)\n VALUES ($1, 'commit', $2, $3::TEXT, $4, $5, $6, $7, $8)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq",
"type_info": "Int8"
}
],
"columns": [],
"parameters": {
"Left": [
"Text",
@@ -21,9 +15,7 @@
"Text"
]
},
"nullable": [
false
]
"nullable": []
},
"hash": "a325862f897484bb1a8863fd7a1bc3fd17ab20b6e30498f9900daf80609c413d"
"hash": "800b93a38eef70bafdf0a1b221994f3c19ab562f84d18265376296a592b4c767"
}
@@ -1,14 +1,8 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n RETURNING seq\n ",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq",
"type_info": "Int8"
}
],
"columns": [],
"parameters": {
"Left": [
"Text",
@@ -23,9 +17,7 @@
"Text"
]
},
"nullable": [
false
]
"nullable": []
},
"hash": "97dd3cfe72b74b4dac686c030a07f6eb56dabb2df72815f3248760b4e3f7950e"
"hash": "82276a34f473ff8b9790b78915ae3f72873d4b748cdb47bd2b81edc2f7a6e5a8"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM repo_seq WHERE did = $1 AND seq != $2",
"query": "DELETE FROM repo_seq WHERE did = $1 AND id <> $2",
"describe": {
"columns": [],
"parameters": {
@@ -11,5 +11,5 @@
},
"nullable": []
},
"hash": "b6d6548acb89d6384cd226f6ed0d66de27fde3af24b4a7a3fce7e098812e38a5"
"hash": "856e458c1c78f030545dc147f76d1b91fa4cc6e178eb51133c52358855ba3f02"
}
@@ -1,14 +1,8 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, active, status)\n VALUES ($1, 'account', $2, $3)\n RETURNING seq\n ",
"query": "\n INSERT INTO repo_seq (did, event_type, active, status)\n VALUES ($1, 'account', $2, $3)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq",
"type_info": "Int8"
}
],
"columns": [],
"parameters": {
"Left": [
"Text",
@@ -16,9 +10,7 @@
"Text"
]
},
"nullable": [
false
]
"nullable": []
},
"hash": "623042c0b901e17ff37c5bcc1a4eefe56fc8b996ca8326e75796897d13c9e9ed"
"hash": "8ccd42b5b1b75f45e094889ee26af8517de28ca687efa81fd9c4fce598338fe9"
}
@@ -1,11 +1,11 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC",
"query": "SELECT seq as \"seq!\", did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq",
"name": "seq!",
"type_info": "Int8"
},
{
@@ -90,7 +90,7 @@
]
},
"nullable": [
false,
true,
false,
false,
false,
@@ -108,5 +108,5 @@
true
]
},
"hash": "c47746c256fd8c9d386551f3bd270e9a96fecdb713642d07d60fb4aeb02c8426"
"hash": "957bc81c63510ab502d2f8cea75dafcbdf2c8ebb448c86990d5e404ee5280438"
}
@@ -1,14 +1,8 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, rev, block_cids, block_data)\n VALUES ($1, 'sync', $2, $3, $4, $5)\n RETURNING seq\n ",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, rev, block_cids, block_data)\n VALUES ($1, 'sync', $2, $3, $4, $5)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq",
"type_info": "Int8"
}
],
"columns": [],
"parameters": {
"Left": [
"Text",
@@ -18,9 +12,7 @@
"ByteaArray"
]
},
"nullable": [
false
]
"nullable": []
},
"hash": "5996774d097a484f81353d49e4a99b07c30a003377c924add576bfb48ddde29b"
"hash": "9ba7869f29286cfd6b811ffa1e26bda1d637b98b44be66cdc265b1af4cec2d4f"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT pg_advisory_xact_lock($1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "pg_advisory_xact_lock",
"type_info": "Void"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "a06e1d9f6f95e4c4c2b98310ebddcc9d963cc033582bf2e945e8bf3a301b4247"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT setval('firehose_seq', $1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "setval",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "bd0407818c8bdae1f1024777951b08cb9c386fe751722e1caf6ad243e271697a"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "NOTIFY repo_updates",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "d4eacb9bca838dd18dcfd8ca39b83ef50968bfaaabc213536c049cc222ffe9c8"
}
@@ -1,11 +1,11 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
"query": "SELECT seq as \"seq!\", did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq",
"name": "seq!",
"type_info": "Int8"
},
{
@@ -91,7 +91,7 @@
]
},
"nullable": [
false,
true,
false,
false,
false,
@@ -109,5 +109,5 @@
true
]
},
"hash": "89c8ce80d8c52b4668c34f63e48b51cacc7e8ff300958a192378f1d72de04f9a"
"hash": "da2770e44c0ec21756ce4b6b0c57abff4f38cd8c680c5ced7d3b35f3c1f58da7"
}
@@ -1,11 +1,11 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
"query": "SELECT seq as \"seq!\", did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq",
"name": "seq!",
"type_info": "Int8"
},
{
@@ -91,7 +91,7 @@
]
},
"nullable": [
false,
true,
false,
false,
false,
@@ -109,5 +109,5 @@
true
]
},
"hash": "0349b2af4d21105029ecc2bf81ad7be9987ec17ac088730596849d7a45558e57"
"hash": "dfe011b2d2bafbbbbea508a4977fc34bb1be4cf2612296df23763e6e5841c33e"
}
@@ -1,11 +1,11 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC",
"query": "SELECT seq as \"seq!\", did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq",
"name": "seq!",
"type_info": "Int8"
},
{
@@ -91,7 +91,7 @@
]
},
"nullable": [
false,
true,
false,
false,
false,
@@ -109,5 +109,5 @@
true
]
},
"hash": "ad784aff9bb5c1f19f90a3b1c3b3694a3e1aa889b0fe3e55cfebb7af8c620256"
"hash": "f16d41b41a5388997e6ed408da55056173340ed12b60b1a956de87f73f9d0824"
}
Generated
+170 -32
View File
@@ -210,7 +210,7 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
dependencies = [
"asn1-rs-derive",
"asn1-rs-derive 0.5.1",
"asn1-rs-impl",
"displaydoc",
"nom 7.1.3",
@@ -220,6 +220,22 @@ dependencies = [
"time",
]
[[package]]
name = "asn1-rs"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
dependencies = [
"asn1-rs-derive 0.6.0",
"asn1-rs-impl",
"displaydoc",
"nom 7.1.3",
"num-traits",
"rusticata-macros",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.5.1"
@@ -232,6 +248,18 @@ dependencies = [
"synstructure",
]
[[package]]
name = "asn1-rs-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
@@ -1046,7 +1074,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
"bit-vec 0.8.0",
]
[[package]]
@@ -1055,6 +1083,15 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bit-vec"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "2.11.0"
@@ -1970,7 +2007,21 @@ version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
dependencies = [
"asn1-rs",
"asn1-rs 0.6.2",
"displaydoc",
"nom 7.1.3",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "der-parser"
version = "10.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
dependencies = [
"asn1-rs 0.7.2",
"displaydoc",
"nom 7.1.3",
"num-bigint",
@@ -2834,6 +2885,34 @@ dependencies = [
"tracing",
]
[[package]]
name = "h3"
version = "0.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be"
dependencies = [
"bytes",
"fastrand",
"futures-util",
"http 1.4.0",
"pin-project-lite",
"tokio",
]
[[package]]
name = "h3-quinn"
version = "0.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4"
dependencies = [
"bytes",
"futures",
"h3",
"quinn",
"tokio",
"tokio-util",
]
[[package]]
name = "half"
version = "2.7.1"
@@ -4614,7 +4693,16 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
dependencies = [
"asn1-rs",
"asn1-rs 0.6.2",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
dependencies = [
"asn1-rs 0.7.2",
]
[[package]]
@@ -5184,7 +5272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
dependencies = [
"bit-set",
"bit-vec",
"bit-vec 0.8.0",
"bitflags",
"num-traits",
"rand 0.9.2",
@@ -5427,6 +5515,7 @@ checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"futures-io",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
@@ -5630,6 +5719,20 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "rcgen"
version = "0.14.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"x509-parser 0.18.1",
"yasna",
]
[[package]]
name = "redis"
version = "1.1.0"
@@ -7527,7 +7630,7 @@ dependencies = [
[[package]]
name = "tranquil-api"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"anyhow",
"axum",
@@ -7578,7 +7681,7 @@ dependencies = [
[[package]]
name = "tranquil-auth"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"anyhow",
"base32",
@@ -7601,7 +7704,7 @@ dependencies = [
[[package]]
name = "tranquil-cache"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7615,7 +7718,7 @@ dependencies = [
[[package]]
name = "tranquil-comms"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7641,7 +7744,7 @@ dependencies = [
[[package]]
name = "tranquil-config"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"confique",
"serde",
@@ -7649,7 +7752,7 @@ dependencies = [
[[package]]
name = "tranquil-crypto"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"aes-gcm",
"base64 0.22.1",
@@ -7665,7 +7768,7 @@ dependencies = [
[[package]]
name = "tranquil-db"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"async-trait",
"chrono",
@@ -7682,7 +7785,7 @@ dependencies = [
[[package]]
name = "tranquil-db-traits"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7698,7 +7801,7 @@ dependencies = [
[[package]]
name = "tranquil-infra"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"async-trait",
"bytes",
@@ -7709,7 +7812,7 @@ dependencies = [
[[package]]
name = "tranquil-lexicon"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"chrono",
"futures",
@@ -7728,7 +7831,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"anyhow",
"axum",
@@ -7751,7 +7854,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth-server"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"axum",
"base64 0.22.1",
@@ -7784,7 +7887,7 @@ dependencies = [
[[package]]
name = "tranquil-pds"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"aes-gcm",
"anyhow",
@@ -7837,6 +7940,7 @@ dependencies = [
"sha2",
"sqlx",
"subtle",
"tempfile",
"testcontainers",
"testcontainers-modules",
"thiserror 2.0.18",
@@ -7877,7 +7981,7 @@ dependencies = [
[[package]]
name = "tranquil-repo"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"bytes",
"cid",
@@ -7889,7 +7993,7 @@ dependencies = [
[[package]]
name = "tranquil-ripple"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"async-trait",
"backon",
@@ -7914,7 +8018,7 @@ dependencies = [
[[package]]
name = "tranquil-scopes"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"axum",
"futures",
@@ -7930,17 +8034,23 @@ dependencies = [
[[package]]
name = "tranquil-server"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"arc-swap",
"axum",
"bytes",
"clap",
"dotenvy",
"ed25519-dalek",
"futures-util",
"h3",
"h3-quinn",
"hex",
"http 1.4.0",
"hyper 1.8.1",
"hyper-util",
"quinn",
"rcgen",
"rustls 0.23.37",
"rustls-pemfile",
"thiserror 2.0.18",
@@ -7960,7 +8070,7 @@ dependencies = [
[[package]]
name = "tranquil-signal"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"async-trait",
"chrono",
@@ -7983,7 +8093,7 @@ dependencies = [
[[package]]
name = "tranquil-storage"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"async-trait",
"aws-config",
@@ -8000,7 +8110,7 @@ dependencies = [
[[package]]
name = "tranquil-store"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"async-trait",
"bytes",
@@ -8049,7 +8159,7 @@ dependencies = [
[[package]]
name = "tranquil-sync"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"anyhow",
"axum",
@@ -8071,7 +8181,7 @@ dependencies = [
[[package]]
name = "tranquil-types"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"chrono",
"cid",
@@ -8584,7 +8694,7 @@ checksum = "15784340a24c170ce60567282fb956a0938742dbfbf9eff5df793a686a009b8b"
dependencies = [
"base64 0.21.7",
"base64urlsafedata",
"der-parser",
"der-parser 9.0.0",
"hex",
"nom 7.1.3",
"openssl",
@@ -8600,7 +8710,7 @@ dependencies = [
"uuid",
"webauthn-attestation-ca",
"webauthn-rs-proto",
"x509-parser",
"x509-parser 0.16.0",
]
[[package]]
@@ -9154,17 +9264,35 @@ version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
dependencies = [
"asn1-rs",
"asn1-rs 0.6.2",
"data-encoding",
"der-parser",
"der-parser 9.0.0",
"lazy_static",
"nom 7.1.3",
"oid-registry",
"oid-registry 0.7.1",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "x509-parser"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
dependencies = [
"asn1-rs 0.7.2",
"data-encoding",
"der-parser 10.0.0",
"lazy_static",
"nom 7.1.3",
"oid-registry 0.8.1",
"ring",
"rusticata-macros",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "xattr"
version = "1.6.1"
@@ -9193,6 +9321,16 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "yasna"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
dependencies = [
"bit-vec 0.9.1",
"time",
]
[[package]]
name = "yoke"
version = "0.8.1"
+5 -1
View File
@@ -26,7 +26,7 @@ members = [
]
[workspace.package]
version = "0.6.3"
version = "0.6.5"
edition = "2024"
license = "AGPL-3.0-or-later"
@@ -82,6 +82,8 @@ foca = { version = "1", features = ["bincode-codec", "tracing"] }
futures = "0.3"
futures-util = "0.3"
governor = "0.10"
h3 = "0.0.8"
h3-quinn = "0.0.10"
hex = "0.4"
hickory-resolver = { version = "0.24", features = ["tokio-runtime"] }
hkdf = "0.12"
@@ -106,7 +108,9 @@ parking_lot = "0.12"
multihash = "0.19"
p256 = { version = "0.13", features = ["ecdsa"] }
p384 = { version = "0.13", features = ["ecdsa"] }
quinn = { version = "0.11", default-features = false, features = ["runtime-tokio", "rustls-ring", "log"] }
rand = "0.8"
rcgen = { version = "0.14", default-features = false, features = ["ring", "pem"] }
redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] }
regex = "1"
rsa = "0.9"
+1 -1
View File
@@ -6,7 +6,7 @@ RUN pnpm install --frozen-lockfile
COPY frontend/ ./
RUN pnpm build
FROM rust:1.92-alpine AS builder
FROM rust:1.96-alpine AS builder
RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static mold clang protoc
ENV RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold"
WORKDIR /app
+1 -1
View File
@@ -1,4 +1,4 @@
FROM rust:1.92-alpine
FROM rust:1.96-alpine
RUN apk add --no-cache \
ca-certificates \
+1
View File
@@ -92,6 +92,7 @@ We currently don't have a shared space to chat and organize Tranquil things, but
- [@sans-self.org](https://tangled.org/did:plc:wydyrngmxbcsqdvhmd7whmye)
- [@tachyonism.tngl.sh](https://tangled.org/did:plc:w6qiwij62bmdugsd3gemhpy2)
- [@trezy.codes](https://tangled.org/did:plc:4jrld6fwpnwqehtce56qshzv)
- [@ave.zone](https://tangled.org/did:plc:mchrltkrhuzpxleiwpmmvpar)
- Could be your name here too!
### Tranquil PDS instances in the wild!
+1 -1
View File
@@ -3,5 +3,5 @@ mod sign;
mod submit;
pub use request::request_plc_operation_signature;
pub use sign::{ServiceInput, SignPlcOperationInput, SignPlcOperationOutput, sign_plc_operation};
pub use sign::{SignPlcOperationInput, SignPlcOperationOutput, sign_plc_operation};
pub use submit::{SubmitPlcOperationInput, submit_plc_operation};
@@ -6,10 +6,6 @@ use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{Auth, Permissive};
use tranquil_pds::state::AppState;
fn generate_plc_token() -> String {
tranquil_pds::util::generate_token_code()
}
pub async fn request_plc_operation_signature(
State(state): State<AppState>,
auth: Auth<Permissive>,
@@ -28,12 +24,13 @@ pub async fn request_plc_operation_signature(
.ok_or(ApiError::AccountNotFound)?;
let _ = state.repos.infra.delete_plc_tokens_for_user(user_id).await;
let plc_token = generate_plc_token();
let display_token = tranquil_pds::util::generate_token_code();
let stored_token = tranquil_pds::util::normalize_token_code(&display_token);
let expires_at = Utc::now() + Duration::minutes(10);
state
.repos
.infra
.insert_plc_token(user_id, &plc_token, expires_at)
.insert_plc_token(user_id, &stored_token, expires_at)
.await
.log_db_err("creating PLC token")?;
@@ -42,7 +39,7 @@ pub async fn request_plc_operation_signature(
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
&plc_token,
&display_token,
hostname,
)
.await
+5 -24
View File
@@ -9,7 +9,7 @@ use tranquil_pds::api::ApiError;
use tranquil_pds::api::error::DbResultExt;
use tranquil_pds::auth::{Auth, Permissive};
use tranquil_pds::circuit_breaker::with_circuit_breaker;
use tranquil_pds::plc::{PlcError, PlcService, ServiceType, create_update_op, sign_operation};
use tranquil_pds::plc::{PlcError, PlcService, create_update_op, sign_operation};
use tranquil_pds::state::AppState;
#[derive(Debug, Deserialize)]
@@ -19,14 +19,7 @@ pub struct SignPlcOperationInput {
pub rotation_keys: Option<Vec<String>>,
pub also_known_as: Option<Vec<String>>,
pub verification_methods: Option<HashMap<String, String>>,
pub services: Option<HashMap<String, ServiceInput>>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ServiceInput {
#[serde(rename = "type")]
pub service_type: ServiceType,
pub endpoint: String,
pub services: Option<HashMap<String, PlcService>>,
}
#[derive(Debug, Serialize)]
@@ -50,9 +43,10 @@ pub async fn sign_plc_operation(
"PLC operations are only valid for did:plc identities".into(),
));
}
let token = input.token.as_ref().ok_or_else(|| {
let raw_token = input.token.as_ref().ok_or_else(|| {
ApiError::InvalidRequest("Email confirmation token required to sign PLC operations".into())
})?;
let token = &tranquil_pds::util::normalize_token_code(raw_token);
let user_id = state
.repos
@@ -106,25 +100,12 @@ pub async fn sign_plc_operation(
if last_op.is_tombstone() {
return Err(ApiError::from(PlcError::Tombstoned));
}
let services = input.services.map(|s| {
s.into_iter()
.map(|(k, v)| {
(
k,
PlcService {
service_type: v.service_type,
endpoint: v.endpoint,
},
)
})
.collect()
});
let unsigned_op = create_update_op(
&last_op,
input.rotation_keys,
input.verification_methods,
input.also_known_as,
services,
input.services,
)
.map_err(|e| match e {
PlcError::Tombstoned => ApiError::InvalidRequest("Cannot update tombstoned DID".into()),
@@ -129,20 +129,13 @@ pub async fn submit_plc_operation(
.await
.map_err(ApiError::from)?;
match state
if let Err(e) = state
.repos
.repo
.insert_identity_event(did, Some(&user.handle))
.await
{
Ok(seq) => {
if let Err(e) = state.repos.repo.notify_update(seq).await {
warn!("Failed to notify identity event: {:?}", e);
}
}
Err(e) => {
warn!("Failed to sequence identity event: {:?}", e);
}
warn!("Failed to sequence identity event: {:?}", e);
}
let _ = state
.cache
+12 -2
View File
@@ -15,8 +15,12 @@ pub mod temp;
use tranquil_pds::state::AppState;
pub fn api_routes() -> axum::Router<AppState> {
use axum::extract::DefaultBodyLimit;
use axum::routing::{get, post};
let blob_body_limit =
DefaultBodyLimit::max(tranquil_config::get().server.max_blob_size as usize);
axum::Router::new()
.route("/_health", get(server::health))
.route(
@@ -68,7 +72,10 @@ pub fn api_routes() -> axum::Router<AppState> {
.route("/com.atproto.repo.deleteRecord", post(repo::delete_record))
.route("/com.atproto.repo.listRecords", get(repo::list_records))
.route("/com.atproto.repo.describeRepo", get(repo::describe_repo))
.route("/com.atproto.repo.uploadBlob", post(repo::upload_blob))
.route(
"/com.atproto.repo.uploadBlob",
post(repo::upload_blob).layer(blob_body_limit),
)
.route("/com.atproto.repo.applyWrites", post(repo::apply_writes))
.route(
"/com.atproto.server.checkAccountStatus",
@@ -247,7 +254,10 @@ pub fn api_routes() -> axum::Router<AppState> {
"/_identity.verifyHandleOwnership",
post(identity::verify_handle_ownership),
)
.route("/com.atproto.repo.importRepo", post(repo::import_repo))
.route(
"/com.atproto.repo.importRepo",
post(repo::import_repo).layer(blob_body_limit),
)
.route(
"/com.atproto.admin.deleteAccount",
post(admin::delete_account),
+7 -77
View File
@@ -77,27 +77,6 @@ pub async fn import_repo(
blocks.len(),
root
);
let Some(root_block) = blocks.get(&root) else {
return Err(ApiError::InvalidRequest(
"Root block not found in CAR file".into(),
));
};
let commit_did: Did = match jacquard_repo::commit::Commit::from_cbor(root_block) {
Ok(commit) => commit
.did()
.as_str()
.parse()
.map_err(|_| ApiError::InvalidRequest("Commit contains invalid DID".into()))?,
Err(e) => {
return Err(ApiError::InvalidRequest(format!("Invalid commit: {}", e)));
}
};
if commit_did != *did {
return Err(ApiError::InvalidRepo(format!(
"CAR file is for DID {} but you are authenticated as {}",
commit_did, did
)));
}
let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION")
.ok()
.map(|v| v == "true" || v == "1")
@@ -108,11 +87,13 @@ pub async fn import_repo(
});
let is_migration = user.inbound_migration && user.deactivated_at.is_some();
if skip_verification {
warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)");
} else if is_migration {
debug!("Verifying CAR file structure for migration (skipping signature verification)");
warn!("Skipping all CAR verification for repo import (SKIP_IMPORT_VERIFICATION=true)");
} else {
debug!(
"Verifying CAR file structure for repo import (skipping signature and DID verification)"
);
let verifier = CarVerifier::new();
match verifier.verify_car_structure_only(did, &root, &blocks) {
match verifier.verify_car_structure_only(&root, &blocks) {
Ok(verified) => {
debug!(
"CAR structure verification successful: rev={}, data_cid={}",
@@ -142,56 +123,6 @@ pub async fn import_repo(
)));
}
}
} else {
debug!("Verifying CAR file signature and structure for DID {}", did);
let verifier = CarVerifier::new();
match verifier.verify_car(did, &root, &blocks).await {
Ok(verified) => {
debug!(
"CAR verification successful: rev={}, data_cid={}",
verified.rev, verified.data_cid
);
}
Err(tranquil_pds::sync::verify::VerifyError::DidMismatch {
commit_did,
expected_did,
}) => {
return Err(ApiError::InvalidRepo(format!(
"CAR file is for DID {} but you are authenticated as {}",
commit_did, expected_did
)));
}
Err(tranquil_pds::sync::verify::VerifyError::InvalidSignature) => {
return Err(ApiError::InvalidRequest(
"CAR file commit signature verification failed".into(),
));
}
Err(tranquil_pds::sync::verify::VerifyError::DidResolutionFailed(msg)) => {
warn!("DID resolution failed during import verification: {}", msg);
return Err(ApiError::InvalidRequest(format!(
"Failed to verify DID: {}",
msg
)));
}
Err(tranquil_pds::sync::verify::VerifyError::NoSigningKey) => {
return Err(ApiError::InvalidRequest(
"DID document does not contain a signing key".into(),
));
}
Err(tranquil_pds::sync::verify::VerifyError::MstValidationFailed(msg)) => {
return Err(ApiError::InvalidRequest(format!(
"MST validation failed: {}",
msg
)));
}
Err(e) => {
error!("CAR verification error: {:?}", e);
return Err(ApiError::InvalidRequest(format!(
"CAR verification failed: {}",
e
)));
}
}
}
let max_blocks = tranquil_config::get().import.max_blocks as usize;
let _write_lock = state.repo_write_locks.lock(user_id).await;
@@ -417,7 +348,6 @@ async fn sequence_import_event(
rev: None,
};
let seq = state.repos.repo.insert_commit_event(&data).await?;
state.repos.repo.notify_update(seq).await?;
state.repos.repo.insert_commit_event(&data).await?;
Ok(())
}
+61 -45
View File
@@ -1,6 +1,6 @@
use super::validation::validate_record_with_status;
use super::validation_mode::{ValidationMode, deserialize_validation_mode};
use crate::repo::record::write::CommitInfo;
use crate::repo::record::write::{CommitInfo, ensure_record_type};
use axum::{Json, extract::State};
use jacquard_repo::{mst::Mst, storage::BlockStore};
use serde::{Deserialize, Serialize};
@@ -14,8 +14,8 @@ use tranquil_pds::auth::{
};
use tranquil_pds::repo::TrackingBlockStore;
use tranquil_pds::repo_ops::{
FinalizeParams, RecordOp, begin_repo_write, extract_backlinks, extract_blob_cids,
finalize_repo_write,
CommitResult, FinalizeParams, RecordOp, begin_repo_write, extract_backlinks, extract_blob_cids,
finalize_repo_write, with_repair_retry,
};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
@@ -54,6 +54,8 @@ async fn process_single_write(
rkey,
value,
} => {
let value = ensure_record_type(value, collection);
let value = &*value;
let validation_status = if validate.should_skip() {
None
} else {
@@ -72,7 +74,7 @@ async fn process_single_write(
if mst
.get(&key)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Failed to read MST: {e}"))))?
.map_err(|e| ApiError::from_mst_error("read MST for applyWrites create", &e))?
.is_some()
{
return Err(ApiError::InvalidRequest(format!(
@@ -90,7 +92,7 @@ async fn process_single_write(
let new_mst = mst
.add(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
.map_err(|e| ApiError::from_mst_error("add record to MST", &e))?;
let uri = AtUri::from_parts(did, collection, &rkey);
backlinks_to_add.extend(extract_backlinks(&uri, value));
results.push(WriteResult::CreateResult {
@@ -117,6 +119,8 @@ async fn process_single_write(
rkey,
value,
} => {
let value = ensure_record_type(value, collection);
let value = &*value;
let validation_status = if validate.should_skip() {
None
} else {
@@ -134,9 +138,7 @@ async fn process_single_write(
let prev_record_cid = mst
.get(&key)
.await
.map_err(|e| {
ApiError::InternalError(Some(format!("Failed to read prev record: {}", e)))
})?
.map_err(|e| ApiError::from_mst_error("read update target from MST", &e))?
.ok_or_else(|| {
ApiError::InvalidRequest("Update target record does not exist".into())
})?;
@@ -151,7 +153,7 @@ async fn process_single_write(
let new_mst = mst
.update(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to update MST".into())))?;
.map_err(|e| ApiError::from_mst_error("update record in MST", &e))?;
let uri = AtUri::from_parts(did, collection, rkey);
backlinks_to_remove.push(uri.clone());
backlinks_to_add.extend(extract_backlinks(&uri, value));
@@ -180,16 +182,14 @@ async fn process_single_write(
let prev_record_cid = mst
.get(&key)
.await
.map_err(|e| {
ApiError::InternalError(Some(format!("Failed to read prev record: {}", e)))
})?
.map_err(|e| ApiError::from_mst_error("read delete target from MST", &e))?
.ok_or_else(|| {
ApiError::InvalidRequest("Delete target record does not exist".into())
})?;
let new_mst = mst
.delete(&key)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to delete from MST".into())))?;
.map_err(|e| ApiError::from_mst_error("delete record from MST", &e))?;
backlinks_to_remove.push(AtUri::from_parts(did, collection, rkey));
results.push(WriteResult::DeleteResult {});
ops.push(RecordOp::Delete {
@@ -232,6 +232,45 @@ async fn process_writes(
.await
}
async fn execute_apply_writes(
state: &AppState,
user_id: uuid::Uuid,
did: &Did,
input: &ApplyWritesInput,
controller_did: Option<&Did>,
write_summary: Option<serde_json::Value>,
) -> Result<(CommitResult, Vec<WriteResult>), ApiError> {
let (ctx, mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
let WriteAccumulator {
mst: final_mst,
results,
ops,
all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
} = process_writes(&input.writes, mst, did, input.validate, &ctx.tracking_store).await?;
let commit_result = finalize_repo_write(
state,
ctx,
final_mst,
FinalizeParams {
did,
user_id,
controller_did,
delegation_detail: write_summary,
ops,
blob_cids: &all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
},
)
.await?;
Ok((commit_result, results))
}
#[derive(Deserialize)]
#[serde(tag = "$type")]
pub enum WriteOp {
@@ -346,24 +385,6 @@ pub async fn apply_writes(
.log_db_err("fetching user for batch write")?
.ok_or(ApiError::InternalError(Some("User not found".into())))?;
let (ctx, mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
let WriteAccumulator {
mst: final_mst,
results,
ops,
all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
} = process_writes(
&input.writes,
mst,
&did,
input.validate,
&ctx.tracking_store,
)
.await?;
let write_summary: Option<serde_json::Value> = controller_did.as_ref().map(|_| {
let writes: Vec<serde_json::Value> = input
.writes
@@ -397,21 +418,16 @@ pub async fn apply_writes(
})
});
let commit_result = finalize_repo_write(
&state,
ctx,
final_mst,
FinalizeParams {
did: &did,
let (commit_result, results) = with_repair_retry(&state, user_id, || {
execute_apply_writes(
&state,
user_id,
controller_did: controller_did.as_ref(),
delegation_detail: write_summary,
ops,
blob_cids: &all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
},
)
&did,
&input,
controller_did.as_ref(),
write_summary.clone(),
)
})
.await?;
Ok(Json(ApplyWritesOutput {
+40 -21
View File
@@ -4,13 +4,15 @@ use cid::Cid;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::str::FromStr;
use tracing::error;
use tranquil_pds::api::error::ApiError;
use tranquil_pds::auth::{Active, Auth, VerifyScope};
use tranquil_pds::cid_types::RecordCid;
use tranquil_pds::repo_ops::{FinalizeParams, RecordOp, begin_repo_write, finalize_repo_write};
use tranquil_pds::repo_ops::{
FinalizeParams, RecordOp, begin_repo_write, finalize_repo_write, with_repair_retry,
};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{AtIdentifier, AtUri, Nsid, Rkey};
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
use uuid::Uuid;
#[derive(Deserialize)]
pub struct DeleteRecordInput {
@@ -41,13 +43,30 @@ pub async fn delete_record(
let user_id = repo_auth.user_id;
let controller_did = repo_auth.controller_did;
let (ctx, mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
let out = with_repair_retry(&state, user_id, || {
delete_record_inner(&state, &did, user_id, controller_did.as_ref(), &input)
})
.await?;
Ok(Json(out))
}
async fn delete_record_inner(
state: &AppState,
did: &Did,
user_id: Uuid,
controller_did: Option<&Did>,
input: &DeleteRecordInput,
) -> Result<DeleteRecordOutput, ApiError> {
let (ctx, mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
let key = format!("{}/{}", input.collection, input.rkey);
if let Some(swap_record_str) = &input.swap_record {
let expected_cid = Cid::from_str(swap_record_str).ok();
let actual_cid = mst.get(&key).await.ok().flatten();
let actual_cid = mst
.get(&key)
.await
.map_err(|e| ApiError::from_mst_error("read swap target from MST", &e))?;
if expected_cid != actual_cid {
return Err(ApiError::InvalidSwap(Some(
"Record has been modified or does not exist".into(),
@@ -55,18 +74,18 @@ pub async fn delete_record(
}
}
let prev_record_cid = mst.get(&key).await.map_err(|e| {
error!("Failed to read prev record from MST: {}", e);
ApiError::InternalError(Some("Failed to read MST".into()))
})?;
let prev_record_cid = mst
.get(&key)
.await
.map_err(|e| ApiError::from_mst_error("read prev record from MST", &e))?;
let Some(prev_record_cid) = prev_record_cid else {
return Ok(Json(DeleteRecordOutput { commit: None }));
return Ok(DeleteRecordOutput { commit: None });
};
let new_mst = mst.delete(&key).await.map_err(|e| {
error!("Failed to delete from MST: {}", e);
ApiError::InternalError(Some("Failed to delete from MST".into()))
})?;
let new_mst = mst
.delete(&key)
.await
.map_err(|e| ApiError::from_mst_error("delete record from MST", &e))?;
let op = RecordOp::Delete {
collection: input.collection.clone(),
@@ -74,17 +93,17 @@ pub async fn delete_record(
prev: RecordCid::from(prev_record_cid),
};
let deleted_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
let deleted_uri = AtUri::from_parts(did, &input.collection, &input.rkey);
let commit_result = finalize_repo_write(
&state,
state,
ctx,
new_mst,
FinalizeParams {
did: &did,
did,
user_id,
controller_did: controller_did.as_ref(),
delegation_detail: controller_did.as_ref().map(|_| {
controller_did,
delegation_detail: controller_did.map(|_| {
json!({
"action": "delete",
"collection": input.collection,
@@ -99,10 +118,10 @@ pub async fn delete_record(
)
.await?;
Ok(Json(DeleteRecordOutput {
Ok(DeleteRecordOutput {
commit: Some(CommitInfo {
cid: commit_result.commit_cid.to_string(),
rev: commit_result.rev,
}),
}))
})
}
+100 -45
View File
@@ -5,8 +5,8 @@ use cid::Cid;
use jacquard_repo::storage::BlockStore;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::borrow::Cow;
use std::str::FromStr;
use tracing::error;
use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{
Active, Auth, AuthSource, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated,
@@ -14,7 +14,7 @@ use tranquil_pds::auth::{
};
use tranquil_pds::repo_ops::{
FinalizeParams, RecordOp, begin_repo_write, extract_backlinks, extract_blob_cids,
finalize_repo_write,
finalize_repo_write, with_repair_retry,
};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
@@ -62,6 +62,29 @@ pub async fn prepare_repo_write<A: RepoScopeAction>(
})
}
pub(crate) fn ensure_record_type<'a>(
record: &'a serde_json::Value,
collection: &Nsid,
) -> Cow<'a, serde_json::Value> {
let serde_json::Value::Object(map) = record else {
return Cow::Borrowed(record);
};
let needs_fill = match map.get("$type") {
None | Some(serde_json::Value::Null) => true,
Some(serde_json::Value::String(existing)) => existing.is_empty(),
Some(_) => false,
};
if !needs_fill {
return Cow::Borrowed(record);
}
let mut map = map.clone();
map.insert(
"$type".to_string(),
serde_json::Value::String(collection.to_string()),
);
Cow::Owned(serde_json::Value::Object(map))
}
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct CreateRecordInput {
@@ -95,15 +118,32 @@ pub struct CreateRecordOutput {
pub async fn create_record(
State(state): State<AppState>,
auth: Auth<Active>,
Json(input): Json<CreateRecordInput>,
Json(mut input): Json<CreateRecordInput>,
) -> Result<Json<CreateRecordOutput>, ApiError> {
if let Cow::Owned(record) = ensure_record_type(&input.record, &input.collection) {
input.record = record;
}
let scope_proof = auth.verify_repo_create(&input.collection)?;
let repo_auth = prepare_repo_write(&state, &scope_proof, &input.repo).await?;
let did = repo_auth.did;
let user_id = repo_auth.user_id;
let controller_did = repo_auth.controller_did;
let (ctx, mut mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
let out = with_repair_retry(&state, user_id, || {
create_record_inner(&state, &did, user_id, controller_did.as_ref(), &input)
})
.await?;
Ok(Json(out))
}
async fn create_record_inner(
state: &AppState,
did: &Did,
user_id: Uuid,
controller_did: Option<&Did>,
input: &CreateRecordInput,
) -> Result<CreateRecordOutput, ApiError> {
let (ctx, mut mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
let validation_status = if input.validate.should_skip() {
None
@@ -119,12 +159,12 @@ pub async fn create_record(
)
};
let rkey = input.rkey.unwrap_or_else(Rkey::generate);
let rkey = input.rkey.clone().unwrap_or_else(Rkey::generate);
let mut ops: Vec<RecordOp> = Vec::new();
let mut conflict_uris_to_cleanup: Vec<AtUri> = Vec::new();
if !input.validate.should_skip() {
let record_uri = AtUri::from_parts(&did, &input.collection, &rkey);
let record_uri = AtUri::from_parts(did, &input.collection, &rkey);
let backlinks = extract_backlinks(&record_uri, &input.record);
if !backlinks.is_empty() {
@@ -149,24 +189,18 @@ pub async fn create_record(
Ok(Some(cid)) => cid,
Ok(None) => continue,
Err(e) => {
error!(
"Failed to read conflict record from MST {}: {:?}",
conflict_uri, e
);
return Err(ApiError::InternalError(Some(
"Failed to read conflicting record from MST".into(),
)));
return Err(ApiError::from_mst_error(
&format!("read conflict record from MST {conflict_uri}"),
&e,
));
}
};
mst = mst.delete(&conflict_key).await.map_err(|e| {
error!(
"Failed to delete conflict from MST {}: {:?}",
conflict_uri, e
);
ApiError::InternalError(Some(
"Failed to delete conflicting record from MST".into(),
))
ApiError::from_mst_error(
&format!("delete conflict from MST {conflict_uri}"),
&e,
)
})?;
ops.push(RecordOp::Delete {
@@ -183,7 +217,7 @@ pub async fn create_record(
if mst
.get(&key)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Failed to read MST: {e}"))))?
.map_err(|e| ApiError::from_mst_error("read MST for create existence check", &e))?
.is_some()
{
return Err(ApiError::InvalidRequest(format!(
@@ -202,7 +236,7 @@ pub async fn create_record(
mst = mst
.add(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
.map_err(|e| ApiError::from_mst_error("add record to MST", &e))?;
ops.push(RecordOp::Create {
collection: input.collection.clone(),
@@ -212,18 +246,18 @@ pub async fn create_record(
let blob_cids = extract_blob_cids(&input.record);
let created_uri = AtUri::from_parts(&did, &input.collection, &rkey);
let created_uri = AtUri::from_parts(did, &input.collection, &rkey);
let backlinks_to_add = extract_backlinks(&created_uri, &input.record);
let commit_result = finalize_repo_write(
&state,
state,
ctx,
mst,
FinalizeParams {
did: &did,
did,
user_id,
controller_did: controller_did.as_ref(),
delegation_detail: controller_did.as_ref().map(|_| {
controller_did,
delegation_detail: controller_did.map(|_| {
json!({
"action": "create",
"collection": input.collection,
@@ -238,7 +272,7 @@ pub async fn create_record(
)
.await?;
Ok(Json(CreateRecordOutput {
Ok(CreateRecordOutput {
uri: created_uri,
cid: record_cid.to_string(),
commit: CommitInfo {
@@ -246,7 +280,7 @@ pub async fn create_record(
rev: commit_result.rev,
},
validation_status,
}))
})
}
#[derive(Deserialize)]
@@ -278,15 +312,32 @@ pub struct PutRecordOutput {
pub async fn put_record(
State(state): State<AppState>,
auth: Auth<Active>,
Json(input): Json<PutRecordInput>,
Json(mut input): Json<PutRecordInput>,
) -> Result<Json<PutRecordOutput>, ApiError> {
if let Cow::Owned(record) = ensure_record_type(&input.record, &input.collection) {
input.record = record;
}
let upsert_proof = auth.verify_repo_upsert(&input.collection)?;
let repo_auth = prepare_repo_write(&state, &upsert_proof, &input.repo).await?;
let did = repo_auth.did;
let user_id = repo_auth.user_id;
let controller_did = repo_auth.controller_did;
let (ctx, mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
let out = with_repair_retry(&state, user_id, || {
put_record_inner(&state, &did, user_id, controller_did.as_ref(), &input)
})
.await?;
Ok(Json(out))
}
async fn put_record_inner(
state: &AppState,
did: &Did,
user_id: Uuid,
controller_did: Option<&Did>,
input: &PutRecordInput,
) -> Result<PutRecordOutput, ApiError> {
let (ctx, mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
let validation_status = if input.validate.should_skip() {
None
@@ -304,9 +355,13 @@ pub async fn put_record(
let key = format!("{}/{}", input.collection, input.rkey);
let read_cid = |r: Result<Option<Cid>, jacquard_repo::error::RepoError>| {
r.map_err(|e| ApiError::from_mst_error("read MST for put", &e))
};
if let Some(swap_record_str) = &input.swap_record {
let expected_cid = Cid::from_str(swap_record_str).ok();
let actual_cid = mst.get(&key).await.ok().flatten();
let actual_cid = read_cid(mst.get(&key).await)?;
if expected_cid != actual_cid {
return Err(ApiError::InvalidSwap(Some(
"Record has been modified or does not exist".into(),
@@ -314,7 +369,7 @@ pub async fn put_record(
}
}
let existing_cid = mst.get(&key).await.ok().flatten();
let existing_cid = read_cid(mst.get(&key).await)?;
let record_ipld = tranquil_pds::util::json_to_ipld(&input.record);
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
@@ -325,21 +380,21 @@ pub async fn put_record(
.map_err(|_| ApiError::InternalError(Some("Failed to save record block".into())))?;
if existing_cid == Some(record_cid) {
return Ok(Json(PutRecordOutput {
uri: AtUri::from_parts(&did, &input.collection, &input.rkey),
return Ok(PutRecordOutput {
uri: AtUri::from_parts(did, &input.collection, &input.rkey),
cid: record_cid.to_string(),
commit: None,
validation_status,
}));
});
}
let record_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
let record_uri = AtUri::from_parts(did, &input.collection, &input.rkey);
let (new_mst, op, is_update, backlinks_to_remove) = match existing_cid {
Some(prev_cid) => {
let new_mst = mst
.update(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to update MST".into())))?;
.map_err(|e| ApiError::from_mst_error("update record in MST", &e))?;
let op = RecordOp::Update {
collection: input.collection.clone(),
rkey: input.rkey.clone(),
@@ -352,7 +407,7 @@ pub async fn put_record(
let new_mst = mst
.add(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
.map_err(|e| ApiError::from_mst_error("add record to MST", &e))?;
let op = RecordOp::Create {
collection: input.collection.clone(),
rkey: input.rkey.clone(),
@@ -366,14 +421,14 @@ pub async fn put_record(
let backlinks_to_add = extract_backlinks(&record_uri, &input.record);
let commit_result = finalize_repo_write(
&state,
state,
ctx,
new_mst,
FinalizeParams {
did: &did,
did,
user_id,
controller_did: controller_did.as_ref(),
delegation_detail: controller_did.as_ref().map(|_| {
controller_did,
delegation_detail: controller_did.map(|_| {
json!({
"action": if is_update { "update" } else { "create" },
"collection": input.collection,
@@ -388,7 +443,7 @@ pub async fn put_record(
)
.await?;
Ok(Json(PutRecordOutput {
Ok(PutRecordOutput {
uri: record_uri,
cid: record_cid.to_string(),
commit: Some(CommitInfo {
@@ -396,5 +451,5 @@ pub async fn put_record(
rev: commit_result.rev,
}),
validation_status,
}))
})
}
@@ -665,27 +665,22 @@ pub async fn delete_account(
error!("DB error deleting account: {:?}", e);
ApiError::InternalError(None)
})?;
let account_seq = tranquil_pds::repo_ops::sequence_account_event(
if let Err(e) = tranquil_pds::repo_ops::sequence_account_event(
&state,
did,
tranquil_db_traits::AccountStatus::Deleted,
)
.await;
match account_seq {
Ok(seq) => {
if let Err(e) = state.repos.repo.delete_sequences_except(did, seq).await {
warn!(
"Failed to cleanup sequences for deleted account {}: {}",
did, e
);
}
}
Err(e) => {
warn!(
"Failed to sequence account deletion event for {}: {}",
did, e
);
}
.await
{
warn!(
"Failed to sequence account deletion event for {}: {}",
did, e
);
} else if let Err(e) = state.repos.repo.purge_did_events_keeping_latest(did).await {
warn!(
"Failed to cleanup sequences for deleted account {}: {}",
did, e
);
}
let _ = state
.cache
+11 -8
View File
@@ -13,10 +13,6 @@ use tranquil_pds::state::AppState;
use tranquil_pds::types::PlainPassword;
use tranquil_pds::validation::validate_password;
fn generate_reset_code() -> String {
tranquil_pds::util::generate_token_code()
}
#[derive(Deserialize)]
pub struct RequestPasswordResetInput {
#[serde(alias = "identifier")]
@@ -70,12 +66,13 @@ pub async fn request_password_reset(
return Err(ApiError::InternalError(None));
}
};
let code = generate_reset_code();
let display_code = tranquil_pds::util::generate_token_code();
let stored_code = tranquil_pds::util::normalize_token_code(&display_code);
let expires_at = Utc::now() + Duration::minutes(10);
if let Err(e) = state
.repos
.user
.set_password_reset_code(user_id, &code, expires_at)
.set_password_reset_code(user_id, &stored_code, expires_at)
.await
{
error!("DB error setting reset code: {:?}", e);
@@ -86,7 +83,7 @@ pub async fn request_password_reset(
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
&code,
&display_code,
hostname,
)
.await
@@ -133,7 +130,13 @@ pub async fn reset_password(
if let Err(e) = validate_password(password) {
return Err(ApiError::InvalidRequest(e.to_string()));
}
let user = match state.repos.user.get_user_by_reset_code(token).await {
let normalized_token = tranquil_pds::util::normalize_token_code(token);
let user = match state
.repos
.user
.get_user_by_reset_code(&normalized_token)
.await
{
Ok(Some(u)) => u,
Ok(None) => {
return Err(ApiError::InvalidToken(None));
+23 -2
View File
@@ -15,7 +15,10 @@ use tranquil_pds::auth::{
Active, Auth, NormalizedLoginIdentifier, Permissive, require_legacy_session_mfa,
require_reauth_window,
};
use tranquil_pds::rate_limit::{LoginLimit, RateLimited, RefreshSessionLimit};
use tranquil_pds::rate_limit::{
LoginLimit, RateLimited, RefreshSessionLimit, TotpVerifyLimit,
check_user_rate_limit_with_message,
};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{AccountState, Did, Handle, PlainPassword};
use tranquil_types::TokenId;
@@ -168,17 +171,35 @@ pub async fn create_session(
let has_totp = row.totp_enabled;
let email_2fa_enabled = row.email_2fa_enabled;
let is_legacy_login = has_totp || email_2fa_enabled;
let used_totp_factor = tranquil_pds::auth::legacy_2fa::used_totp_factor(
has_totp,
input.auth_factor_token.as_deref(),
);
let twofa_ctx = tranquil_pds::auth::legacy_2fa::Legacy2faContext {
is_app_password: app_password_name.is_some(),
email_2fa_enabled,
has_totp,
allow_legacy_login: row.allow_legacy_login,
};
if let Some(token) = input.auth_factor_token.as_deref()
&& has_totp
&& tranquil_pds::auth::legacy_2fa::looks_like_totp_token(token)
{
check_user_rate_limit_with_message::<TotpVerifyLimit>(
&state,
row.did.as_str(),
"Too many verification attempts. Please try again in a few minutes.",
)
.await?;
}
match tranquil_pds::auth::legacy_2fa::process_legacy_2fa(
state.cache.as_ref(),
&row.did,
&twofa_ctx,
input.auth_factor_token.as_deref(),
async |t: &str| {
crate::server::totp::verify_totp_or_backup_for_user(&state, &row.did, t).await
},
)
.await
{
@@ -285,7 +306,7 @@ pub async fn create_session(
error!("Failed to insert session: {:?}", e);
return Err(ApiError::InternalError(None));
}
if is_legacy_login {
if is_legacy_login && !used_totp_factor {
warn!(
did = %row.did,
ip = %client_ip,
+5 -5
View File
@@ -35,7 +35,7 @@ pub fn create_access_token_with_scope_metadata(
scope,
TokenType::Access,
key_bytes,
Duration::minutes(15),
Duration::minutes(120),
hostname,
)
}
@@ -54,7 +54,7 @@ pub fn create_access_token_with_delegation(
scope,
TokenType::Access,
key_bytes,
Duration::minutes(15),
Duration::minutes(120),
act,
hostname,
)
@@ -69,7 +69,7 @@ pub fn create_refresh_token_with_metadata(
TokenScope::Refresh.as_str(),
TokenType::Refresh,
key_bytes,
Duration::days(14),
Duration::days(90),
None,
)
}
@@ -198,7 +198,7 @@ pub fn create_access_token_hs256_with_metadata(
TokenScope::Access.as_str(),
TokenType::Access,
secret,
Duration::minutes(15),
Duration::minutes(120),
)
}
@@ -211,7 +211,7 @@ pub fn create_refresh_token_hs256_with_metadata(
TokenScope::Refresh.as_str(),
TokenType::Refresh,
secret,
Duration::days(14),
Duration::days(90),
)
}
+14 -1
View File
@@ -15,7 +15,9 @@ impl TokenType {
match self {
Self::Access => "at+jwt",
Self::Refresh => "refresh+jwt",
Self::Service => "jwt",
// RFC 7519 §5.1 recommends the uppercase "JWT".
// and for atproto inter-service auth its a requirement.
Self::Service => "JWT",
}
}
}
@@ -290,6 +292,17 @@ mod tests {
assert!(TokenType::from_str("bearer").is_err());
}
#[test]
fn service_token_header_serializes_typ_as_uppercase_jwt() {
// RFC 7519 §5.1 recommends the JWT `typ` header value be uppercase "JWT".
let header = Header {
alg: SigningAlgorithm::ES256K,
typ: TokenType::Service,
};
let json = serde_json::to_string(&header).expect("serialize header");
assert!(json.contains(r#""typ":"JWT""#), "got {json}");
}
#[test]
fn signing_algorithm_case_insensitive() {
assert_eq!(
+33 -4
View File
@@ -502,6 +502,7 @@ pub struct ServerConfig {
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct TlsConfig {
/// The path to the TLS cert chain.
/// If you set both this and `key_path`, the server terminates TLS itself rather than expecting
@@ -512,6 +513,11 @@ pub struct TlsConfig {
/// Path to the TLS private key.
#[config(env = "TLS_KEY_PATH")]
pub key_path: Option<String>,
/// Serve HTTP/3 over QUIC on the same UDP port as the TCP listener.
/// Requires cert_path and key_path.
#[config(env = "TLS_HTTP3", default = false)]
pub http3: bool,
}
impl TlsConfig {
@@ -531,6 +537,13 @@ impl TlsConfig {
.to_string(),
);
}
if self.http3 && self.material().is_none() {
errors.push(
"server.tls.http3 (TLS_HTTP3) requires server.tls.cert_path \
and erver.tls.key_path"
.to_string(),
);
}
}
}
@@ -1638,10 +1651,8 @@ not_a_real_field = "oops"
#[test]
fn load_accepts_known_keys() {
let dir = std::env::temp_dir().join(format!(
"tranquil-config-known-keys-{}",
std::process::id()
));
let dir =
std::env::temp_dir().join(format!("tranquil-config-known-keys-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir tempdir");
let path = dir.join("config.toml");
std::fs::write(
@@ -1800,6 +1811,7 @@ port = 587
TlsConfig {
cert_path: None,
key_path: None,
http3: false,
}
.validate(&mut errors);
assert!(errors.is_empty(), "expected no errors, got {errors:?}");
@@ -1811,6 +1823,7 @@ port = 587
TlsConfig {
cert_path: Some("/etc/tranquil/cert.pem".to_string()),
key_path: Some("/etc/tranquil/key.pem".to_string()),
http3: false,
}
.validate(&mut errors);
assert!(errors.is_empty(), "expected no errors, got {errors:?}");
@@ -1822,6 +1835,7 @@ port = 587
TlsConfig {
cert_path: Some("/etc/tranquil/cert.pem".to_string()),
key_path: None,
http3: false,
}
.validate(&mut errors);
assert!(
@@ -1830,6 +1844,21 @@ port = 587
);
}
#[test]
fn tls_validate_rejects_http3_without_material() {
let mut errors = Vec::new();
TlsConfig {
cert_path: None,
key_path: None,
http3: true,
}
.validate(&mut errors);
assert!(
errors.iter().any(|e| e.contains("http3")),
"expected http3 error, got {errors:?}"
);
}
#[derive(Default)]
struct EmailOverrides {
from_address: Option<&'static str>,
+15 -18
View File
@@ -319,7 +319,6 @@ pub struct ApplyCommitInput {
#[derive(Debug, Clone)]
pub struct ApplyCommitResult {
pub seq: i64,
pub is_account_active: bool,
}
@@ -445,19 +444,15 @@ pub trait RepoRepository: Send + Sync {
async fn count_user_blocks(&self, user_id: Uuid) -> Result<i64, DbError>;
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<SequenceNumber, DbError>;
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<(), DbError>;
async fn insert_identity_event(
&self,
did: &Did,
handle: Option<&Handle>,
) -> Result<SequenceNumber, DbError>;
) -> Result<(), DbError>;
async fn insert_account_event(
&self,
did: &Did,
status: AccountStatus,
) -> Result<SequenceNumber, DbError>;
async fn insert_account_event(&self, did: &Did, status: AccountStatus) -> Result<(), DbError>;
async fn insert_sync_event(
&self,
@@ -465,7 +460,7 @@ pub trait RepoRepository: Send + Sync {
commit_cid: &CidLink,
rev: Option<&str>,
commit_bytes: &[u8],
) -> Result<SequenceNumber, DbError>;
) -> Result<(), DbError>;
async fn insert_genesis_commit_event(
&self,
@@ -475,14 +470,18 @@ pub trait RepoRepository: Send + Sync {
rev: &str,
commit_bytes: &[u8],
mst_root_bytes: &[u8],
) -> Result<SequenceNumber, DbError>;
async fn delete_sequences_except(
&self,
did: &Did,
keep_seq: SequenceNumber,
) -> Result<(), DbError>;
async fn purge_did_events_keeping_latest(&self, did: &Did) -> Result<(), DbError>;
async fn assign_pending_sequences(&self) -> Result<u64, DbError> {
Ok(0)
}
async fn flush_pending_sequences(&self) -> Result<(), DbError> {
Ok(())
}
async fn prune_events_older_than(&self, cutoff: DateTime<Utc>) -> Result<PruneCount, DbError>;
async fn get_max_seq(&self) -> Result<SequenceNumber, DbError>;
@@ -526,8 +525,6 @@ pub trait RepoRepository: Send + Sync {
async fn get_repo_root_cid_by_user_id(&self, user_id: Uuid)
-> Result<Option<CidLink>, DbError>;
async fn notify_update(&self, seq: SequenceNumber) -> Result<(), DbError>;
async fn import_repo_data(
&self,
user_id: Uuid,
@@ -563,5 +560,5 @@ pub trait RepoEventNotifier: Send + Sync {
#[async_trait]
pub trait RepoEventReceiver: Send {
async fn recv(&mut self) -> Option<i64>;
async fn recv(&mut self) -> Option<()>;
}
+1 -1
View File
@@ -551,7 +551,7 @@ pub trait UserRepository: Send + Sync {
limit: i64,
) -> Result<Vec<ScheduledDeletionAccount>, DbError>;
async fn delete_account_with_firehose(&self, user_id: Uuid, did: &Did) -> Result<i64, DbError>;
async fn delete_account_with_firehose(&self, user_id: Uuid, did: &Did) -> Result<(), DbError>;
async fn create_password_account(
&self,
@@ -35,9 +35,9 @@ pub struct PostgresRepoEventReceiver {
#[async_trait]
impl RepoEventReceiver for PostgresRepoEventReceiver {
async fn recv(&mut self) -> Option<i64> {
async fn recv(&mut self) -> Option<()> {
match self.listener.recv().await {
Ok(notification) => notification.payload().parse().ok(),
Ok(_) => Some(()),
Err(_) => None,
}
}
+145 -88
View File
@@ -111,6 +111,64 @@ fn map_sequenced_row(r: SequencedEventRow) -> Result<SequencedEvent, DbError> {
})
}
const SEQUENCER_LOCK_KEY: i64 = 0x0074_7261_6e73_6571;
const SEQUENCER_BATCH_SIZE: i64 = 1000;
async fn notify_repo_pending(pool: &PgPool) {
if let Err(e) = sqlx::query!("NOTIFY repo_updates").execute(pool).await {
tracing::warn!(error = %e, "failed to notify repo_updates listeners");
}
}
async fn assign_one_batch(
mut tx: sqlx::Transaction<'_, sqlx::Postgres>,
pool: &PgPool,
) -> Result<i64, DbError> {
let pending_ids: Vec<i64> = sqlx::query_scalar!(
r#"SELECT id as "id!" FROM repo_seq WHERE seq IS NULL ORDER BY id LIMIT $1"#,
SEQUENCER_BATCH_SIZE
)
.fetch_all(&mut *tx)
.await
.map_err(map_sqlx_error)?;
let count = pending_ids.len() as i64;
if count == 0 {
tx.commit().await.map_err(map_sqlx_error)?;
return Ok(0);
}
let start = sqlx::query_scalar!(r#"SELECT nextval('firehose_seq') as "seq!""#)
.fetch_one(&mut *tx)
.await
.map_err(map_sqlx_error)?;
if count > 1 {
sqlx::query!("SELECT setval('firehose_seq', $1)", start + count - 1)
.fetch_one(&mut *tx)
.await
.map_err(map_sqlx_error)?;
}
let assigned = sqlx::query!(
r#"UPDATE repo_seq r
SET seq = $1::bigint + (o.ord - 1)
FROM unnest($2::bigint[]) WITH ORDINALITY AS o(id, ord)
WHERE r.id = o.id"#,
start,
&pending_ids
)
.execute(&mut *tx)
.await
.map_err(map_sqlx_error)?
.rows_affected();
tx.commit().await.map_err(map_sqlx_error)?;
if assigned > 0 {
notify_repo_pending(pool).await;
}
Ok(count)
}
pub struct PostgresRepoRepository {
pool: PgPool,
}
@@ -716,13 +774,12 @@ impl RepoRepository for PostgresRepoRepository {
Ok(rows.into_iter().map(|(cid,)| cid).collect())
}
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<SequenceNumber, DbError> {
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<(), DbError> {
let (block_cids, block_data) = inline_to_paired_blocks(data.blocks.as_deref());
let seq = sqlx::query_scalar!(
sqlx::query!(
r#"
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING seq
"#,
data.did.as_str(),
data.event_type.as_str(),
@@ -735,67 +792,54 @@ impl RepoRepository for PostgresRepoRepository {
data.prev_data_cid.as_ref().map(|c| c.as_str()),
data.rev
)
.fetch_one(&self.pool)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(seq.into())
notify_repo_pending(&self.pool).await;
Ok(())
}
async fn insert_identity_event(
&self,
did: &Did,
handle: Option<&Handle>,
) -> Result<SequenceNumber, DbError> {
) -> Result<(), DbError> {
let handle_str = handle.map(|h| h.as_str());
let seq = sqlx::query_scalar!(
sqlx::query!(
r#"
INSERT INTO repo_seq (did, event_type, handle)
VALUES ($1, 'identity', $2)
RETURNING seq
"#,
did.as_str(),
handle_str
)
.fetch_one(&self.pool)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq))
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(seq.into())
notify_repo_pending(&self.pool).await;
Ok(())
}
async fn insert_account_event(
&self,
did: &Did,
status: AccountStatus,
) -> Result<SequenceNumber, DbError> {
async fn insert_account_event(&self, did: &Did, status: AccountStatus) -> Result<(), DbError> {
let active = status.is_active();
let status_str = status.for_firehose();
let seq = sqlx::query_scalar!(
sqlx::query!(
r#"
INSERT INTO repo_seq (did, event_type, active, status)
VALUES ($1, 'account', $2, $3)
RETURNING seq
"#,
did.as_str(),
active,
status_str
)
.fetch_one(&self.pool)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq))
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(seq.into())
notify_repo_pending(&self.pool).await;
Ok(())
}
async fn insert_sync_event(
@@ -804,18 +848,17 @@ impl RepoRepository for PostgresRepoRepository {
commit_cid: &CidLink,
rev: Option<&str>,
commit_bytes: &[u8],
) -> Result<SequenceNumber, DbError> {
) -> Result<(), DbError> {
let cid_bytes = commit_cid
.to_cid()
.map(|c| c.to_bytes())
.unwrap_or_default();
let block_cids: Vec<Vec<u8>> = vec![cid_bytes];
let block_data: Vec<Vec<u8>> = vec![commit_bytes.to_vec()];
let seq = sqlx::query_scalar!(
sqlx::query!(
r#"
INSERT INTO repo_seq (did, event_type, commit_cid, rev, block_cids, block_data)
VALUES ($1, 'sync', $2, $3, $4, $5)
RETURNING seq
"#,
did.as_str(),
commit_cid.as_str(),
@@ -823,16 +866,12 @@ impl RepoRepository for PostgresRepoRepository {
&block_cids as &[Vec<u8>],
&block_data as &[Vec<u8>]
)
.fetch_one(&self.pool)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq))
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(seq.into())
notify_repo_pending(&self.pool).await;
Ok(())
}
async fn insert_genesis_commit_event(
@@ -843,7 +882,7 @@ impl RepoRepository for PostgresRepoRepository {
rev: &str,
commit_bytes: &[u8],
mst_root_bytes: &[u8],
) -> Result<SequenceNumber, DbError> {
) -> Result<(), DbError> {
let ops = serde_json::json!([]);
let blobs: Vec<String> = vec![];
let commit_cid_bytes = commit_cid
@@ -858,11 +897,10 @@ impl RepoRepository for PostgresRepoRepository {
let block_data: Vec<Vec<u8>> = vec![commit_bytes.to_vec(), mst_root_bytes.to_vec()];
let prev_cid: Option<&str> = None;
let seq = sqlx::query_scalar!(
sqlx::query!(
r#"
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, rev)
VALUES ($1, 'commit', $2, $3::TEXT, $4, $5, $6, $7, $8)
RETURNING seq
"#,
did.as_str(),
commit_cid.as_str(),
@@ -873,27 +911,22 @@ impl RepoRepository for PostgresRepoRepository {
&block_data as &[Vec<u8>],
rev
)
.fetch_one(&self.pool)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq))
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(seq.into())
notify_repo_pending(&self.pool).await;
Ok(())
}
async fn delete_sequences_except(
&self,
did: &Did,
keep_seq: SequenceNumber,
) -> Result<(), DbError> {
async fn purge_did_events_keeping_latest(&self, did: &Did) -> Result<(), DbError> {
sqlx::query!(
"DELETE FROM repo_seq WHERE did = $1 AND seq != $2",
did.as_str(),
keep_seq.as_i64()
r#"
DELETE FROM repo_seq
WHERE did = $1
AND id <> (SELECT id FROM repo_seq WHERE did = $1 ORDER BY id DESC LIMIT 1)
"#,
did.as_str()
)
.execute(&self.pool)
.await
@@ -902,6 +935,42 @@ impl RepoRepository for PostgresRepoRepository {
Ok(())
}
async fn assign_pending_sequences(&self) -> Result<u64, DbError> {
let mut total: u64 = 0;
loop {
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
let acquired = sqlx::query_scalar!(
r#"SELECT pg_try_advisory_xact_lock($1) as "locked!""#,
SEQUENCER_LOCK_KEY
)
.fetch_one(&mut *tx)
.await
.map_err(map_sqlx_error)?;
if !acquired {
return Ok(total);
}
let count = assign_one_batch(tx, &self.pool).await?;
total += count as u64;
if count < SEQUENCER_BATCH_SIZE {
return Ok(total);
}
}
}
async fn flush_pending_sequences(&self) -> Result<(), DbError> {
loop {
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
sqlx::query!("SELECT pg_advisory_xact_lock($1)", SEQUENCER_LOCK_KEY)
.execute(&mut *tx)
.await
.map_err(map_sqlx_error)?;
let count = assign_one_batch(tx, &self.pool).await?;
if count < SEQUENCER_BATCH_SIZE {
return Ok(());
}
}
}
async fn prune_events_older_than(&self, cutoff: DateTime<Utc>) -> Result<PruneCount, DbError> {
let result = sqlx::query!("DELETE FROM repo_seq WHERE created_at < $1", cutoff)
.execute(&self.pool)
@@ -965,7 +1034,7 @@ impl RepoRepository for PostgresRepoRepository {
Some(lim) => {
let rows = sqlx::query_as!(
SequencedEventRow,
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
r#"SELECT seq as "seq!", did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
FROM repo_seq
WHERE seq > $1
@@ -982,7 +1051,7 @@ impl RepoRepository for PostgresRepoRepository {
None => {
let rows = sqlx::query_as!(
SequencedEventRow,
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
r#"SELECT seq as "seq!", did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
FROM repo_seq
WHERE seq > $1
@@ -1004,7 +1073,7 @@ impl RepoRepository for PostgresRepoRepository {
) -> Result<Vec<SequencedEvent>, DbError> {
let rows = sqlx::query_as!(
SequencedEventRow,
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
r#"SELECT seq as "seq!", did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
FROM repo_seq
WHERE seq > $1 AND seq < $2
@@ -1024,7 +1093,7 @@ impl RepoRepository for PostgresRepoRepository {
) -> Result<Option<SequencedEvent>, DbError> {
let row = sqlx::query_as!(
SequencedEventRow,
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
r#"SELECT seq as "seq!", did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
FROM repo_seq
WHERE seq = $1"#,
@@ -1043,7 +1112,7 @@ impl RepoRepository for PostgresRepoRepository {
) -> Result<Vec<SequencedEvent>, DbError> {
let rows = sqlx::query_as!(
SequencedEventRow,
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
r#"SELECT seq as "seq!", did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
FROM repo_seq
WHERE seq > $1
@@ -1104,14 +1173,6 @@ impl RepoRepository for PostgresRepoRepository {
Ok(cid.map(CidLink::from))
}
async fn notify_update(&self, seq: SequenceNumber) -> Result<(), DbError> {
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq.as_i64()))
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn import_repo_data(
&self,
user_id: Uuid,
@@ -1402,28 +1463,27 @@ impl RepoRepository for PostgresRepoRepository {
let event = input.commit_event;
let (event_block_cids, event_block_data) = inline_into_paired_blocks(event.blocks);
let seq: i64 = sqlx::query_scalar(
sqlx::query!(
r#"
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING seq
"#,
event.did.as_str(),
event.event_type.as_str(),
event.commit_cid.as_ref().map(|c| c.as_str()),
event.prev_cid.as_ref().map(|c| c.as_str()),
event.ops,
event.blobs.as_deref(),
&event_block_cids as &[Vec<u8>],
&event_block_data as &[Vec<u8>],
event.prev_data_cid.as_ref().map(|c| c.as_str()),
event.rev
)
.bind(event.did.as_str())
.bind(event.event_type.as_str())
.bind(event.commit_cid.as_ref().map(|c| c.as_str()))
.bind(event.prev_cid.as_ref().map(|c| c.as_str()))
.bind(&event.ops)
.bind(&event.blobs)
.bind(&event_block_cids)
.bind(&event_block_data)
.bind(event.prev_data_cid.as_ref().map(|c| c.as_str()))
.bind(&event.rev)
.fetch_one(&mut *tx)
.execute(&mut *tx)
.await
.map_err(|e| ApplyCommitError::Database(e.to_string()))?;
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq))
sqlx::query!("NOTIFY repo_updates")
.execute(&mut *tx)
.await
.map_err(|e| ApplyCommitError::Database(e.to_string()))?;
@@ -1432,10 +1492,7 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(|e| ApplyCommitError::Database(e.to_string()))?;
Ok(tranquil_db_traits::ApplyCommitResult {
seq,
is_account_active,
})
Ok(tranquil_db_traits::ApplyCommitResult { is_account_active })
}
async fn get_users_without_blocks(&self) -> Result<Vec<UserWithoutBlocks>, DbError> {
+7 -7
View File
@@ -2291,7 +2291,7 @@ impl UserRepository for PostgresUserRepository {
.collect())
}
async fn delete_account_with_firehose(&self, user_id: Uuid, did: &Did) -> Result<i64, DbError> {
async fn delete_account_with_firehose(&self, user_id: Uuid, did: &Did) -> Result<(), DbError> {
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
sqlx::query!("DELETE FROM blobs WHERE created_by_user = $1", user_id)
@@ -2370,11 +2370,11 @@ impl UserRepository for PostgresUserRepository {
.await
.map_err(map_sqlx_error)?;
let account_seq: i64 = sqlx::query_scalar!(
let event_id: i64 = sqlx::query_scalar!(
r#"
INSERT INTO repo_seq (did, event_type, active, status)
VALUES ($1, 'account', false, 'deleted')
RETURNING seq
RETURNING id
"#,
did.as_str()
)
@@ -2383,9 +2383,9 @@ impl UserRepository for PostgresUserRepository {
.map_err(map_sqlx_error)?;
sqlx::query!(
"DELETE FROM repo_seq WHERE did = $1 AND seq != $2",
"DELETE FROM repo_seq WHERE did = $1 AND id <> $2",
did.as_str(),
account_seq
event_id
)
.execute(&mut *tx)
.await
@@ -2393,12 +2393,12 @@ impl UserRepository for PostgresUserRepository {
tx.commit().await.map_err(map_sqlx_error)?;
sqlx::query(&format!("NOTIFY repo_updates, '{}'", account_seq))
sqlx::query!("NOTIFY repo_updates")
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(account_seq)
Ok(())
}
async fn create_password_account(
+1
View File
@@ -94,6 +94,7 @@ frontend = []
native-tls-roots = ["tranquil-oauth/native-tls-roots"]
[dev-dependencies]
tempfile = "3"
ciborium = { workspace = true }
ctor = { workspace = true }
testcontainers = { workspace = true }
+30 -2
View File
@@ -15,6 +15,7 @@ struct ErrorBody<'a> {
#[derive(Debug)]
pub enum ApiError {
InternalError(Option<String>),
RepoCorruption,
AuthenticationRequired,
AuthenticationFailed(Option<String>),
InvalidRequest(String),
@@ -121,10 +122,34 @@ pub enum ApiError {
},
}
const MST_NODE_MISSING_MARKER: &str = "MST node not found";
impl ApiError {
pub fn is_repo_corruption(&self) -> bool {
matches!(self, Self::RepoCorruption)
}
pub fn detail_is_repo_corruption(detail: &str) -> bool {
detail.contains(tranquil_store::blockstore::BLOCK_CORRUPTION_MARKER)
|| detail.contains(MST_NODE_MISSING_MARKER)
}
pub fn from_mst_error(context: &str, e: &jacquard_repo::error::RepoError) -> Self {
let detail = format!("{e:#}");
if Self::detail_is_repo_corruption(&detail) {
tracing::warn!("{context}: repairable MST damage: {detail}");
Self::RepoCorruption
} else {
tracing::error!("{context}: {detail}");
Self::InternalError(None)
}
}
fn status_code(&self) -> StatusCode {
match self {
Self::InternalError(_) | Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
Self::InternalError(_) | Self::RepoCorruption | Self::DatabaseError => {
StatusCode::INTERNAL_SERVER_ERROR
}
Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => {
StatusCode::BAD_GATEWAY
}
@@ -223,7 +248,9 @@ impl ApiError {
}
fn error_name(&self) -> Cow<'static, str> {
match self {
Self::InternalError(_) | Self::DatabaseError => Cow::Borrowed("InternalServerError"),
Self::InternalError(_) | Self::RepoCorruption | Self::DatabaseError => {
Cow::Borrowed("InternalServerError")
}
Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => {
Cow::Borrowed("UpstreamError")
}
@@ -332,6 +359,7 @@ impl ApiError {
Self::InternalError(msg) => msg
.clone()
.unwrap_or_else(|| "Internal Server Error".into()),
Self::RepoCorruption => "Internal Server Error".into(),
Self::AuthenticationFailed(msg) => msg
.clone()
.unwrap_or_else(|| "Authentication failed".into()),
+48 -2
View File
@@ -109,6 +109,35 @@ fn is_protected_method(method: &str) -> bool {
PROTECTED_METHODS.contains(method)
}
/// Fetch the `feed` generator record from the AppView and return its `did`.
async fn resolve_feed_generator_did(appview_url: &str, query: Option<&str>) -> Option<String> {
#[derive(serde::Deserialize)]
struct GetFeedQuery {
feed: String,
}
let feed = serde_urlencoded::from_str::<GetFeedQuery>(query?)
.ok()?
.feed;
let at_uri = crate::types::AtUri::new(feed).ok()?;
let repo = at_uri.did()?;
let collection = at_uri.collection()?;
let rkey = at_uri.rkey()?;
let resp = proxy_client()
.get(format!("{appview_url}/xrpc/com.atproto.repo.getRecord"))
.query(&[("repo", repo), ("collection", collection), ("rkey", rkey)])
.send()
.await
.ok()?;
if !resp.status().is_success() {
warn!(status = %resp.status(), "getFeed proxy: getRecord for feed generator failed");
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
body.get("value")?.get("did")?.as_str().map(str::to_string)
}
pub struct XrpcProxyLayer {
state: AppState,
}
@@ -287,10 +316,27 @@ async fn proxy_handler(
},
};
// getFeed must be audienced to the feed generator, not the AppView.
let (token_aud, token_lxm) = if method == "app.bsky.feed.getFeed" {
match resolve_feed_generator_did(&resolved.url, query.as_deref()).await {
Some(feed_did) => (feed_did, "app.bsky.feed.getFeedSkeleton"),
None => {
warn!(
"getFeed proxy: could not resolve feed generator DID; refusing \
to mint an AppView-audienced token"
);
return ApiError::InvalidRequest("Could not resolve feed".into())
.into_response();
}
}
} else {
(resolved.did.clone(), method)
};
match crate::auth::create_service_token(
&auth_user.did,
&resolved.did,
Some(method),
&token_aud,
Some(token_lxm),
&key_bytes,
) {
Ok(new_token) => {
+31 -27
View File
@@ -1,11 +1,10 @@
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::cache::Cache;
use crate::util::{generate_token_code, normalize_token_code};
const TOKEN_TTL_SECS: u64 = 900;
const BASE32_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmailTokenPurpose {
@@ -46,14 +45,6 @@ fn cache_key(did: &str, purpose: EmailTokenPurpose) -> String {
format!("email_token:{}:{}", purpose.as_str(), did)
}
fn generate_short_token() -> String {
let mut rng = rand::thread_rng();
let token: String = (0..10)
.map(|_| BASE32_CHARS[rng.gen_range(0..BASE32_CHARS.len())] as char)
.collect();
format!("{}-{}", &token[0..5], &token[5..10])
}
fn current_timestamp() -> u64 {
u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0)
}
@@ -67,9 +58,9 @@ pub async fn create_email_token(
return Err(TokenError::CacheUnavailable);
}
let token = generate_short_token();
let token = generate_token_code();
let data = TokenData {
token: token.clone(),
token: normalize_token_code(&token),
created_at: current_timestamp(),
};
@@ -108,10 +99,9 @@ pub async fn validate_email_token(
return Err(TokenError::ExpiredToken);
}
let normalized_input = token.to_uppercase().replace('-', "");
let normalized_stored = data.token.to_uppercase().replace('-', "");
let normalized_input = normalize_token_code(token);
if !constant_time_eq(normalized_input.as_bytes(), normalized_stored.as_bytes()) {
if !constant_time_eq(normalized_input.as_bytes(), data.token.as_bytes()) {
return Err(TokenError::InvalidToken);
}
@@ -260,20 +250,19 @@ mod tests {
#[tokio::test]
async fn test_token_format() {
(0..100).for_each(|_| {
let token = generate_short_token();
// The emitted token is the display form: uppercase `XXXXX-XXXXX`.
let cache = MockCache::new();
let did = "did:plc:test123";
(0..50).for_each(|_| {
let token = futures::executor::block_on(create_email_token(
&cache,
did,
EmailTokenPurpose::UpdateEmail,
))
.unwrap();
assert_eq!(token.len(), 11);
assert_eq!(&token[5..6], "-");
assert!(
token[0..5]
.chars()
.all(|c| BASE32_CHARS.contains(&(c as u8)))
);
assert!(
token[6..11]
.chars()
.all(|c| BASE32_CHARS.contains(&(c as u8)))
);
assert_eq!(token, token.to_uppercase());
});
}
@@ -292,6 +281,21 @@ mod tests {
assert!(result.is_ok());
}
#[tokio::test]
async fn test_hyphen_insensitive_validation() {
let cache = MockCache::new();
let did = "did:plc:test123";
let token = create_email_token(&cache, did, EmailTokenPurpose::UpdateEmail)
.await
.unwrap();
let no_hyphen = token.replace('-', "");
let result =
validate_email_token(&cache, did, EmailTokenPurpose::UpdateEmail, &no_hyphen).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_noop_cache_returns_unavailable() {
let cache = crate::cache::NoOpCache;
+227 -31
View File
@@ -1,15 +1,14 @@
use chrono::Utc;
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::cache::Cache;
use crate::types::Did;
use crate::util::{generate_token_code, normalize_token_code};
const CHALLENGE_TTL_SECS: u64 = 300;
const MIN_REMAINING_TTL_SECS: u64 = 10;
const MAX_ATTEMPTS: u8 = 5;
const CODE_LENGTH: usize = 8;
const COOLDOWN_SECS: u64 = 60;
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -94,7 +93,8 @@ async fn validate_challenge_internal(
return Err(ValidationError::ChallengeExpired);
}
if !constant_time_eq(code.as_bytes(), data.code.as_bytes()) {
let normalized_input = normalize_token_code(code);
if !constant_time_eq(normalized_input.as_bytes(), data.code.as_bytes()) {
let updated = ChallengeData {
code: data.code,
attempts: data.attempts + 1,
@@ -127,17 +127,19 @@ fn cooldown_key(did: &str) -> String {
format!("legacy_2fa_cooldown:{}", did)
}
fn generate_code() -> String {
let mut rng = rand::thread_rng();
(0..CODE_LENGTH)
.map(|_| rng.gen_range(0..10).to_string())
.collect()
}
fn current_timestamp() -> u64 {
u64::try_from(Utc::now().timestamp()).unwrap_or(0)
}
pub fn looks_like_totp_token(code: &str) -> bool {
let c = code.trim();
(c.len() == 6 && c.bytes().all(|b| b.is_ascii_digit())) || crate::auth::is_backup_code_format(c)
}
pub fn used_totp_factor(has_totp: bool, auth_factor_token: Option<&str>) -> bool {
has_totp && auth_factor_token.is_some_and(looks_like_totp_token)
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
@@ -148,6 +150,7 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
== 0
}
#[derive(Debug)]
pub enum Legacy2faOutcome {
NotRequired,
Blocked,
@@ -177,6 +180,7 @@ pub async fn process_legacy_2fa(
did: &Did,
ctx: &Legacy2faContext,
auth_factor_token: Option<&str>,
verify_totp: impl AsyncFnOnce(&str) -> bool,
) -> Result<Legacy2faOutcome, Legacy2faFlowError> {
if !ctx.requires_2fa() {
return Ok(Legacy2faOutcome::NotRequired);
@@ -192,8 +196,16 @@ pub async fn process_legacy_2fa(
Ok(Legacy2faOutcome::ChallengeSent(code))
}
Some(token) => {
validate_challenge(cache, did, token).await?;
Ok(Legacy2faOutcome::Verified)
if ctx.has_totp && looks_like_totp_token(token) {
if verify_totp(token).await {
Ok(Legacy2faOutcome::Verified)
} else {
Err(Legacy2faFlowError::Validation(ValidationError::InvalidCode))
}
} else {
validate_challenge(cache, did, token).await?;
Ok(Legacy2faOutcome::Verified)
}
}
}
}
@@ -219,11 +231,11 @@ async fn create_challenge_code(
return Err(ChallengeError::RateLimited);
}
let code = generate_code();
let display = generate_token_code();
let now = current_timestamp();
let data = ChallengeData {
code: code.clone(),
code: normalize_token_code(&display),
attempts: 0,
created_at: now,
};
@@ -244,7 +256,7 @@ async fn create_challenge_code(
.await
.map_err(|_| ChallengeError::CacheError)?;
Ok(ChallengeCode(code))
Ok(ChallengeCode(display))
}
#[derive(Debug)]
@@ -332,12 +344,46 @@ mod tests {
let did = Did::new("did:plc:test123".to_string()).unwrap();
let code = create_challenge(&cache, &did).await.unwrap();
assert_eq!(code.as_str().len(), CODE_LENGTH);
assert_eq!(code.as_str().len(), 11);
let result = validate_challenge(&cache, &did, code.as_str()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_challenge_code_format() {
let cache = MockCache::new();
let did = Did::new("did:plc:test123".to_string()).unwrap();
let code = create_challenge(&cache, &did).await.unwrap();
let code = code.as_str();
assert_eq!(code.len(), 11);
assert_eq!(&code[5..6], "-");
assert_eq!(code, code.to_uppercase());
}
#[tokio::test]
async fn test_case_insensitive_validation() {
let cache = MockCache::new();
let did = Did::new("did:plc:test123".to_string()).unwrap();
let code = create_challenge(&cache, &did).await.unwrap();
let lowercase = code.as_str().to_lowercase();
let result = validate_challenge(&cache, &did, &lowercase).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_hyphen_insensitive_validation() {
let cache = MockCache::new();
let did = Did::new("did:plc:test123".to_string()).unwrap();
let code = create_challenge(&cache, &did).await.unwrap();
let no_hyphen = code.as_str().replace('-', "");
let result = validate_challenge(&cache, &did, &no_hyphen).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_invalid_code_rejected() {
let cache = MockCache::new();
@@ -396,15 +442,6 @@ mod tests {
assert_eq!(result.unwrap_err(), ChallengeError::CacheUnavailable);
}
#[tokio::test]
async fn test_code_generation_is_numeric() {
(0..100).for_each(|_| {
let code = generate_code();
assert!(code.chars().all(|c| c.is_ascii_digit()));
assert_eq!(code.len(), CODE_LENGTH);
});
}
#[tokio::test]
async fn test_constant_time_eq() {
assert!(constant_time_eq(b"12345678", b"12345678"));
@@ -425,7 +462,9 @@ mod tests {
allow_legacy_login: true,
};
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
}
@@ -440,7 +479,9 @@ mod tests {
allow_legacy_login: true,
};
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
}
@@ -455,7 +496,9 @@ mod tests {
allow_legacy_login: false,
};
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::Blocked));
}
@@ -470,7 +513,9 @@ mod tests {
allow_legacy_login: true,
};
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::ChallengeSent(_)));
}
@@ -485,7 +530,9 @@ mod tests {
allow_legacy_login: false,
};
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::ChallengeSent(_)));
}
@@ -502,7 +549,7 @@ mod tests {
let code = create_challenge(&cache, &did).await.unwrap();
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some(code.as_str()))
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some(code.as_str()), reject_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::Verified));
@@ -532,4 +579,153 @@ mod tests {
let result = validate_challenge(&cache, &did, "12345678").await;
assert_eq!(result.unwrap_err(), ValidationError::CacheUnavailable);
}
async fn reject_totp(_code: &str) -> bool {
false
}
async fn accept_totp(_code: &str) -> bool {
true
}
#[tokio::test]
async fn test_totp_shaped_token_accepted_via_verifier() {
let cache = MockCache::new();
let did = Did::new("did:plc:totp1".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: false,
has_totp: true,
allow_legacy_login: true,
};
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some("123456"), accept_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::Verified));
}
#[tokio::test]
async fn test_totp_shaped_token_rejected_does_not_touch_email_challenge() {
let cache = MockCache::new();
let did = Did::new("did:plc:totp2".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: true,
has_totp: true,
allow_legacy_login: true,
};
// An email challenge exists for this user.
let email_code = create_challenge(&cache, &did).await.unwrap();
// Five wrong TOTP-shaped attempts. If these incremented the email attempt
// counter, the email challenge would be exhausted (MAX_ATTEMPTS = 5).
for _ in 0..5 {
let err = process_legacy_2fa(&cache, &did, &ctx, Some("000000"), reject_totp)
.await
.unwrap_err();
assert!(matches!(
err,
Legacy2faFlowError::Validation(ValidationError::InvalidCode)
));
}
// The email challenge is still valid and consumable.
let outcome =
process_legacy_2fa(&cache, &did, &ctx, Some(email_code.as_str()), reject_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::Verified));
}
#[tokio::test]
async fn test_email_shaped_token_routes_to_email_path_when_totp_present() {
let cache = MockCache::new();
let did = Did::new("did:plc:totp3".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: true,
has_totp: true,
allow_legacy_login: true,
};
let email_code = create_challenge(&cache, &did).await.unwrap();
// reject_totp would fail if this routed to the verifier; it must route to email.
let outcome =
process_legacy_2fa(&cache, &did, &ctx, Some(email_code.as_str()), reject_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::Verified));
}
#[tokio::test]
async fn test_backup_code_shaped_token_routes_to_verifier() {
let cache = MockCache::new();
let did = Did::new("did:plc:totp4".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: false,
has_totp: true,
allow_legacy_login: true,
};
// No email challenge created. If this routed to email it would be
// ChallengeNotFound; Verified proves it went to the verifier.
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some("ABCD2345"), accept_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::Verified));
}
#[tokio::test]
async fn test_totp_shaped_token_ignored_when_no_totp() {
let cache = MockCache::new();
let did = Did::new("did:plc:totp5".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: true,
has_totp: false,
allow_legacy_login: false,
};
// has_totp = false -> 6-digit token routes to email path; no challenge -> NotFound.
let err = process_legacy_2fa(&cache, &did, &ctx, Some("123456"), reject_totp)
.await
.unwrap_err();
assert!(matches!(
err,
Legacy2faFlowError::Validation(ValidationError::ChallengeNotFound)
));
}
#[tokio::test]
async fn test_looks_like_totp_token() {
// 6-digit TOTP codes
assert!(looks_like_totp_token("123456"));
assert!(looks_like_totp_token(" 000000 "));
// backup-code format (8 chars, backup alphabet)
assert!(looks_like_totp_token("ABCD2345"));
// email challenge codes normalize to 10 alphanumeric chars -> not TOTP-shaped
assert!(!looks_like_totp_token("ABCDEFGHIJ"));
assert!(!looks_like_totp_token("ABCDE-FGHIJ"));
// wrong lengths / non-digits
assert!(!looks_like_totp_token("12345"));
assert!(!looks_like_totp_token("1234567"));
assert!(!looks_like_totp_token("12345A"));
assert!(!looks_like_totp_token(""));
}
#[test]
fn test_used_totp_factor() {
// strong MFA factors completed the login -> true
assert!(used_totp_factor(true, Some("123456")));
assert!(used_totp_factor(true, Some("ABCD2345")));
// email-shaped code, or no token, or no TOTP on the account -> false
assert!(!used_totp_factor(true, Some("ABCDEFGHIJ")));
assert!(!used_totp_factor(true, None));
assert!(!used_totp_factor(false, Some("123456")));
assert!(!used_totp_factor(true, Some("")));
}
}
+1 -1
View File
@@ -522,7 +522,7 @@ async fn validate_bearer_token_with_options_internal(
auth_source: AuthSource::OAuth,
});
} else {
return Err(TokenValidationError::TokenExpired);
return Err(TokenValidationError::OAuthTokenExpired);
}
}
+3 -3
View File
@@ -51,6 +51,8 @@ pub const BUILD_VERSION: &str = concat!(
#[cfg(not(debug_assertions))]
pub const BUILD_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const GENERAL_BODY_LIMIT: usize = 16 * 1024 * 1024;
pub struct ExternalRoutes {
pub xrpc: Router<AppState>,
pub oauth: Router<AppState>,
@@ -97,9 +99,7 @@ pub fn app_with_routes(state: AppState, external: ExternalRoutes) -> Router {
.nest("/.well-known", well_known_router)
.route("/metrics", get(metrics::metrics_handler))
.merge(external.extra)
.layer(DefaultBodyLimit::max(
tranquil_config::get().server.max_blob_size as usize,
))
.layer(DefaultBodyLimit::max(GENERAL_BODY_LIMIT))
.layer(axum::middleware::map_response(rewrite_extractor_errors))
.layer(middleware::from_fn(metrics::metrics_middleware))
.layer(
+135 -9
View File
@@ -39,27 +39,77 @@ pub enum PlcOpType {
Tombstone,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Error)]
#[error("service type must not be empty")]
pub struct EmptyServiceType;
mod custom_service_type {
use super::EmptyServiceType;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomServiceType(String);
impl CustomServiceType {
pub(super) fn new(name: String) -> Result<Self, EmptyServiceType> {
match name.as_str() {
"" => Err(EmptyServiceType),
_ => Ok(Self(name)),
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
}
pub use custom_service_type::CustomServiceType;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServiceType {
#[serde(rename = "AtprotoPersonalDataServer")]
Pds,
#[serde(rename = "AtprotoAppView")]
AppView,
#[serde(rename = "AtprotoLabeler")]
Labeler,
Other(CustomServiceType),
}
impl ServiceType {
pub fn as_str(self) -> &'static str {
pub fn as_str(&self) -> &str {
match self {
Self::Pds => "AtprotoPersonalDataServer",
Self::AppView => "AtprotoAppView",
Self::Labeler => "AtprotoLabeler",
Self::Other(name) => name.as_str(),
}
}
}
pub fn is_pds(self) -> bool {
matches!(self, Self::Pds)
impl TryFrom<String> for ServiceType {
type Error = EmptyServiceType;
fn try_from(name: String) -> Result<Self, Self::Error> {
match name.as_str() {
"AtprotoPersonalDataServer" => Ok(Self::Pds),
"AtprotoLabeler" => Ok(Self::Labeler),
_ => CustomServiceType::new(name).map(Self::Other),
}
}
}
impl Serialize for ServiceType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for ServiceType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let name = String::deserialize(deserializer)?;
Self::try_from(name).map_err(serde::de::Error::custom)
}
}
@@ -625,4 +675,80 @@ mod tests {
let signed = sign_operation(&op, &key).unwrap();
assert!(signed.get("sig").is_some());
}
#[test]
fn test_service_type_known_round_trip() {
let cases = [
(ServiceType::Pds, "\"AtprotoPersonalDataServer\""),
(ServiceType::Labeler, "\"AtprotoLabeler\""),
];
cases.iter().for_each(|(variant, encoded)| {
assert_eq!(serde_json::to_string(variant).unwrap(), *encoded);
assert_eq!(
serde_json::from_str::<ServiceType>(encoded).unwrap(),
*variant
);
});
}
#[test]
fn test_service_type_custom_round_trips() {
let parsed: ServiceType = serde_json::from_str("\"ConchFeedGenerator\"").unwrap();
assert_eq!(
parsed,
ServiceType::try_from("ConchFeedGenerator".to_string()).unwrap()
);
assert_eq!(
serde_json::to_string(&parsed).unwrap(),
"\"ConchFeedGenerator\""
);
}
#[test]
fn test_service_type_custom_normalizes_known_names() {
assert_eq!(
ServiceType::try_from("AtprotoPersonalDataServer".to_string()).unwrap(),
ServiceType::Pds
);
assert_eq!(
ServiceType::try_from("AtprotoLabeler".to_string()).unwrap(),
ServiceType::Labeler
);
}
#[test]
fn test_appview_is_not_a_named_type() {
assert_eq!(
ServiceType::try_from("AtprotoAppView".to_string()).unwrap(),
ServiceType::Other(CustomServiceType::new("AtprotoAppView".to_string()).unwrap())
);
}
#[test]
fn test_service_type_rejects_empty() {
assert!(ServiceType::try_from(String::new()).is_err());
assert!(serde_json::from_str::<ServiceType>("\"\"").is_err());
}
#[test]
fn test_plc_operation_with_custom_service_round_trips() {
let op_json = json!({
"type": "plc_operation",
"rotationKeys": ["did:key:zScallop"],
"verificationMethods": { "atproto": "did:key:zUni" },
"alsoKnownAs": ["at://whelk.nel.pet"],
"services": {
"atproto_pds": { "type": "AtprotoPersonalDataServer", "endpoint": "https://nel.pet" },
"custom_feedgen": { "type": "ConchFeedGenerator", "endpoint": "https://feed.nel.pet" }
},
"prev": null
});
let op: PlcOperation = serde_json::from_value(op_json.clone()).unwrap();
assert_eq!(
op.services["custom_feedgen"].service_type,
ServiceType::try_from("ConchFeedGenerator".to_string()).unwrap()
);
assert_eq!(op.services["atproto_pds"].service_type, ServiceType::Pds);
assert_eq!(serde_json::to_value(&op).unwrap(), op_json);
}
}
+36 -3
View File
@@ -7,12 +7,13 @@ use cid::Cid;
use jacquard_repo::error::RepoError;
use jacquard_repo::repo::CommitData;
use jacquard_repo::storage::BlockStore;
use tranquil_store::blockstore::TranquilBlockStore;
use tranquil_store::blockstore::{RepairOutcome, TranquilBlockStore};
use tranquil_store::{RealIO, SystemClock};
#[derive(Clone)]
pub enum AnyBlockStore {
Postgres(PostgresBlockStore),
TranquilStore(TranquilBlockStore),
TranquilStore(TranquilBlockStore<RealIO, SystemClock>),
}
impl AnyBlockStore {
@@ -23,7 +24,7 @@ impl AnyBlockStore {
}
}
pub fn as_tranquil_store(&self) -> Option<&TranquilBlockStore> {
pub fn as_tranquil_store(&self) -> Option<&TranquilBlockStore<RealIO, SystemClock>> {
match self {
Self::TranquilStore(s) => Some(s),
Self::Postgres(_) => None,
@@ -36,6 +37,38 @@ impl AnyBlockStore {
Self::TranquilStore(s) => s.decrement_refs(cids).await,
}
}
pub async fn repair_structure(
&self,
entries: &[(String, Cid)],
expected_root: Cid,
) -> Result<RepairOutcome, RepoError> {
match self {
Self::Postgres(s) => {
let nodes =
tranquil_store::blockstore::rebuild_mst_nodes(entries, expected_root).await?;
let nodes_total = nodes.len();
let cids: Vec<Cid> = nodes.iter().map(|(cid, _)| *cid).collect();
let present = s.get_many(&cids).await?;
let missing: Vec<(Cid, Bytes)> = nodes
.into_iter()
.zip(present)
.filter_map(|((cid, bytes), found)| found.is_none().then_some((cid, bytes)))
.collect();
let nodes_repaired = missing.len() as u64;
if !missing.is_empty() {
s.put_many(missing).await?;
}
Ok(RepairOutcome {
nodes_total,
nodes_repaired,
})
}
Self::TranquilStore(s) => {
tranquil_store::blockstore::rebuild_and_repair_mst(s, entries, expected_root).await
}
}
}
}
impl BlockStore for AnyBlockStore {
+359 -10
View File
@@ -16,10 +16,10 @@ use k256::ecdsa::SigningKey;
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::str::FromStr;
use std::sync::Arc;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
use tokio::sync::OwnedMutexGuard;
use tracing::{error, warn};
use tranquil_db_traits::SequenceNumber;
use uuid::Uuid;
#[derive(Debug)]
@@ -231,16 +231,251 @@ pub async fn begin_repo_write(
Ok((ctx, mst))
}
pub async fn repair_repo_structure(
state: &AppState,
user_id: Uuid,
) -> Result<tranquil_store::blockstore::RepairOutcome, ApiError> {
let _write_lock = state.repo_write_locks.lock(user_id).await;
let root_cid_str = state
.repos
.repo
.get_repo_root_cid_by_user_id(user_id)
.await
.map_err(|e| {
error!("repair: DB error fetching repo root: {}", e);
ApiError::InternalError(None)
})?
.ok_or_else(|| ApiError::InternalError(Some("Repo root not found".into())))?;
let current_root_cid = Cid::from_str(root_cid_str.as_str())
.map_err(|_| ApiError::InternalError(Some("Invalid repo root CID".into())))?;
let commit_bytes = state
.block_store
.get(&current_root_cid)
.await
.map_err(|e| {
error!("repair: failed to load commit block: {}", e);
ApiError::InternalError(None)
})?
.ok_or_else(|| ApiError::InternalError(Some("Commit block not found".into())))?;
let commit = Commit::from_cbor(&commit_bytes).map_err(|e| {
error!("repair: failed to parse commit: {}", e);
ApiError::InternalError(None)
})?;
let data_root = commit.data;
let repo_rev = commit.rev().to_string();
let records = state
.repos
.repo
.get_all_records(user_id)
.await
.map_err(|e| {
error!("repair: get_all_records failed: {}", e);
ApiError::InternalError(None)
})?;
let entries: Vec<(String, Cid)> = records
.into_iter()
.filter_map(|r| {
Cid::from_str(r.record_cid.as_str())
.ok()
.map(|cid| (format!("{}/{}", r.collection, r.rkey), cid))
})
.collect();
warn!(
user_id = %user_id,
records = entries.len(),
"repair: rebuilding full MST from record set"
);
let outcome = state
.block_store
.repair_structure(&entries, data_root)
.await
.map_err(|e| {
error!("repair: structural repair failed: {}", e);
ApiError::InternalError(Some("Structural repair failed".into()))
})?;
if outcome.nodes_repaired > 0 {
let block_cids =
crate::scheduled::collect_current_repo_blocks(&state.block_store, &current_root_cid)
.await
.map_err(|e| {
error!("repair: re-walk for user_blocks backfill failed: {}", e);
ApiError::InternalError(None)
})?;
let cids = block_cids
.iter()
.map(|bytes| Cid::try_from(bytes.as_slice()))
.collect::<Result<Vec<Cid>, _>>()
.map_err(|e| {
error!("repair: unparseable CID in repaired DAG walk: {e}");
ApiError::InternalError(None)
})?;
let present = state.block_store.get_many(&cids).await.map_err(|e| {
error!("repair: presence check during user_blocks backfill failed: {e}");
ApiError::InternalError(None)
})?;
let missing: Vec<Cid> = cids
.iter()
.zip(present)
.filter_map(|(cid, found)| found.is_none().then_some(*cid))
.collect();
if !missing.is_empty() {
error!(
user_id = %user_id,
missing = missing.len(),
sample = ?missing.iter().take(5).map(|c| c.to_string()).collect::<Vec<_>>(),
"repair: unrecoverable leaf data loss after structural repair"
);
return Err(ApiError::InternalError(Some(format!(
"unrecoverable leaf data loss: {} record block(s) missing after structural repair",
missing.len()
))));
}
state
.repos
.repo
.insert_user_blocks(user_id, &block_cids, &repo_rev)
.await
.map_err(|e| {
error!("repair: user_blocks backfill failed: {}", e);
ApiError::InternalError(None)
})?;
warn!(
user_id = %user_id,
blocks = block_cids.len(),
"repair: backfilled user_blocks from repaired DAG"
);
}
Ok(outcome)
}
pub async fn with_repair_retry<T, F, Fut>(
state: &AppState,
user_id: Uuid,
mut attempt: F,
) -> Result<T, ApiError>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, ApiError>>,
{
match attempt().await {
Err(e) if e.is_repo_corruption() => {
warn!(
"structural MST damage during repo write for user {user_id}, repairing and retrying"
);
match repair_repo_structure(state, user_id).await {
Ok(outcome) if outcome.nodes_repaired > 0 => attempt().await,
Ok(_) => {
warn!(
user_id = %user_id,
"structural repair rewrote no nodes; damage is not in the MST structure, returning original error without retry"
);
Err(e)
}
Err(repair_err) => {
error!(user_id = %user_id, "structural repair failed: {repair_err:?}");
Err(e)
}
}
}
other => other,
}
}
const REPAIR_COOLDOWN: Duration = Duration::from_secs(60);
const REPAIR_NOOP_COOLDOWN: Duration = Duration::from_secs(600);
struct RepairSlot {
in_flight: bool,
next_allowed: Instant,
}
struct RepairGuard {
slots: parking_lot::Mutex<HashMap<Uuid, RepairSlot>>,
}
impl RepairGuard {
fn try_claim(&self, user_id: Uuid, now: Instant) -> bool {
let mut slots = self.slots.lock();
let slot = slots.entry(user_id).or_insert(RepairSlot {
in_flight: false,
next_allowed: now,
});
if slot.in_flight || now < slot.next_allowed {
return false;
}
slot.in_flight = true;
true
}
fn release(&self, user_id: Uuid, now: Instant, cooldown: Duration) {
if let Some(slot) = self.slots.lock().get_mut(&user_id) {
slot.in_flight = false;
slot.next_allowed = now + cooldown;
}
}
}
static REPAIR_GUARD: LazyLock<RepairGuard> = LazyLock::new(|| RepairGuard {
slots: parking_lot::Mutex::new(HashMap::new()),
});
struct RepairLease {
user_id: Uuid,
cooldown: Duration,
}
impl Drop for RepairLease {
fn drop(&mut self) {
REPAIR_GUARD.release(self.user_id, Instant::now(), self.cooldown);
}
}
pub fn schedule_repo_repair(state: &AppState, user_id: Uuid) {
if !REPAIR_GUARD.try_claim(user_id, Instant::now()) {
return;
}
let state = state.clone();
tokio::spawn(async move {
let mut lease = RepairLease {
user_id,
cooldown: REPAIR_COOLDOWN,
};
match repair_repo_structure(&state, user_id).await {
Ok(outcome) => {
if outcome.nodes_repaired == 0 {
lease.cooldown = REPAIR_NOOP_COOLDOWN;
}
warn!(
user_id = %user_id,
nodes_repaired = outcome.nodes_repaired,
nodes_total = outcome.nodes_total,
"background MST repair complete"
);
}
Err(e) => error!(user_id = %user_id, "background MST repair failed: {e:?}"),
}
});
}
pub async fn finalize_repo_write(
state: &AppState,
ctx: RepoWriteContext,
mst: Mst<TrackingBlockStore>,
params: FinalizeParams<'_>,
) -> Result<CommitResult, ApiError> {
let new_mst_root = mst.persist().await.map_err(|e| {
error!("MST persist failed: {}", e);
ApiError::InternalError(None)
})?;
let new_mst_root = mst
.persist()
.await
.map_err(|e| ApiError::from_mst_error("MST persist", &e))?;
let written_bytes = ctx.tracking_store.take_written_blocks();
let new_tree_cids: Vec<Cid> = written_bytes.keys().copied().collect();
@@ -776,7 +1011,7 @@ pub async fn sequence_identity_event(
state: &AppState,
did: &Did,
handle: Option<&Handle>,
) -> Result<SequenceNumber, CommitError> {
) -> Result<(), CommitError> {
state
.repos
.repo
@@ -788,7 +1023,7 @@ pub async fn sequence_account_event(
state: &AppState,
did: &Did,
status: tranquil_db_traits::AccountStatus,
) -> Result<SequenceNumber, CommitError> {
) -> Result<(), CommitError> {
state
.repos
.repo
@@ -801,7 +1036,7 @@ pub async fn sequence_sync_event(
did: &Did,
commit_cid: &str,
rev: Option<&str>,
) -> Result<SequenceNumber, CommitError> {
) -> Result<(), CommitError> {
let cid_link: crate::types::CidLink = commit_cid
.parse()
.map_err(|_| CommitError::InvalidCid(commit_cid.to_string()))?;
@@ -829,7 +1064,7 @@ pub async fn sequence_genesis_commit(
commit_cid: &Cid,
mst_root_cid: &Cid,
rev: &str,
) -> Result<SequenceNumber, CommitError> {
) -> Result<(), CommitError> {
let commit_cid_link = crate::types::CidLink::from(commit_cid);
let mst_root_cid_link = crate::types::CidLink::from(mst_root_cid);
let commit_bytes = state
@@ -862,3 +1097,117 @@ pub async fn sequence_genesis_commit(
.await
.map_err(|e| CommitError::DatabaseError(format!("genesis commit event: {}", e)))
}
#[cfg(test)]
mod repair_guard_tests {
use super::*;
fn guard() -> RepairGuard {
RepairGuard {
slots: parking_lot::Mutex::new(HashMap::new()),
}
}
#[test]
fn claim_dedups_in_flight_then_respects_cooldown() {
let g = guard();
let user = Uuid::from_u128(1);
let t0 = Instant::now();
assert!(g.try_claim(user, t0), "first claim must succeed");
assert!(
!g.try_claim(user, t0),
"second claim while a repair is in flight must be rejected"
);
g.release(user, t0, REPAIR_COOLDOWN);
assert!(
!g.try_claim(user, t0 + Duration::from_secs(1)),
"claim within the cooldown window must be rejected"
);
assert!(
g.try_claim(user, t0 + REPAIR_COOLDOWN + Duration::from_millis(1)),
"claim after the cooldown window must succeed"
);
}
#[test]
fn distinct_users_do_not_block_each_other() {
let g = guard();
let t0 = Instant::now();
assert!(g.try_claim(Uuid::from_u128(1), t0));
assert!(g.try_claim(Uuid::from_u128(2), t0));
}
#[test]
fn concurrent_claims_for_one_user_admit_exactly_one() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Barrier};
let g = Arc::new(guard());
let user = Uuid::from_u128(42);
let now = Instant::now();
let winners = Arc::new(AtomicUsize::new(0));
let gate = Arc::new(Barrier::new(32));
let handles: Vec<_> = (0..32)
.map(|_| {
let g = Arc::clone(&g);
let winners = Arc::clone(&winners);
let gate = Arc::clone(&gate);
std::thread::spawn(move || {
gate.wait();
if g.try_claim(user, now) {
winners.fetch_add(1, Ordering::Relaxed);
}
})
})
.collect();
handles
.into_iter()
.for_each(|h| h.join().expect("worker thread panicked"));
assert_eq!(
winners.load(Ordering::Relaxed),
1,
"exactly one concurrent claim must win the dedup race"
);
}
#[test]
fn release_re_enables_claim_after_cooldown_for_recurring_corruption() {
let g = guard();
let user = Uuid::from_u128(7);
let t0 = Instant::now();
assert!(g.try_claim(user, t0));
g.release(user, t0, REPAIR_COOLDOWN);
let after_cooldown = t0 + REPAIR_COOLDOWN + Duration::from_millis(1);
assert!(
g.try_claim(user, after_cooldown),
"a fresh corruption after the cooldown must be repairable again"
);
assert!(
!g.try_claim(user, after_cooldown),
"the re-claimed repair must again dedup while in flight"
);
}
#[test]
fn noop_repair_uses_longer_cooldown() {
let g = guard();
let user = Uuid::from_u128(9);
let t0 = Instant::now();
assert!(g.try_claim(user, t0));
g.release(user, t0, REPAIR_NOOP_COOLDOWN);
assert!(
!g.try_claim(user, t0 + REPAIR_COOLDOWN + Duration::from_millis(1)),
"after a no-op repair the standard cooldown must not re-admit a claim"
);
assert!(
g.try_claim(user, t0 + REPAIR_NOOP_COOLDOWN + Duration::from_millis(1)),
"after the longer no-op cooldown a fresh claim must be admitted"
);
}
}
+59 -16
View File
@@ -574,7 +574,10 @@ impl CompactionBlocklist {
}
fn run_compaction_pass(
store: &tranquil_store::blockstore::TranquilBlockStore,
store: &tranquil_store::blockstore::TranquilBlockStore<
tranquil_store::RealIO,
tranquil_store::SystemClock,
>,
liveness_threshold: f64,
grace_period_ms: u64,
blocklist: &parking_lot::Mutex<CompactionBlocklist>,
@@ -711,7 +714,7 @@ async fn delete_account_data(
warn!(storage_key = %key, error = %e, "Failed to delete blob from storage (continuing anyway)");
});
let _account_seq = user_repo
user_repo
.delete_account_with_firehose(user_id, did)
.await
.context("Failed to delete account")?;
@@ -727,10 +730,53 @@ async fn delete_account_data(
const CAR_BLOCK_BATCH_SIZE: usize = 500;
#[derive(Debug)]
pub enum RepoCarError {
MissingBlocks(Vec<Cid>),
Source(anyhow::Error),
}
impl RepoCarError {
pub fn is_repairable(&self) -> bool {
match self {
Self::MissingBlocks(_) => true,
Self::Source(e) => {
crate::api::error::ApiError::detail_is_repo_corruption(&format!("{e:#}"))
}
}
}
}
impl std::fmt::Display for RepoCarError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingBlocks(cids) => write!(
f,
"repo CAR is incomplete: {} block(s) referenced by the MST are missing from storage. First 5: {}",
cids.len(),
cids.iter()
.take(5)
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(", ")
),
Self::Source(e) => write!(f, "{e:#}"),
}
}
}
impl std::error::Error for RepoCarError {}
impl From<anyhow::Error> for RepoCarError {
fn from(e: anyhow::Error) -> Self {
Self::Source(e)
}
}
pub async fn generate_repo_car(
block_store: &AnyBlockStore,
head_cid: &Cid,
) -> anyhow::Result<Vec<u8>> {
) -> Result<Vec<u8>, RepoCarError> {
let block_cids_bytes = collect_current_repo_blocks(block_store, head_cid).await?;
let block_cids: Vec<Cid> = block_cids_bytes
.iter()
@@ -757,16 +803,7 @@ pub async fn generate_repo_car(
.filter_map(|(cid, block_opt)| block_opt.is_none().then_some(*cid))
.collect();
if !missing.is_empty() {
anyhow::bail!(
"repo CAR is incomplete: {} block(s) referenced by the MST are missing from storage. First 5: {}",
missing.len(),
missing
.iter()
.take(5)
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(", ")
);
return Err(RepoCarError::MissingBlocks(missing));
}
chunk
@@ -800,7 +837,7 @@ pub async fn generate_repo_car_from_user_blocks(
block_store: &AnyBlockStore,
user_id: uuid::Uuid,
_head_cid: &Cid,
) -> anyhow::Result<Vec<u8>> {
) -> Result<Vec<u8>, RepoCarError> {
use std::str::FromStr;
let repo_root_cid_str: String = repo_repo
@@ -836,7 +873,10 @@ fn cid_to_bytes(cid: &Cid) -> anyhow::Result<CidBytes> {
}
fn walk_repo_dag_sync(
store: &tranquil_store::blockstore::TranquilBlockStore,
store: &tranquil_store::blockstore::TranquilBlockStore<
tranquil_store::RealIO,
tranquil_store::SystemClock,
>,
head_cid: &Cid,
reachable: &mut std::collections::HashSet<CidBytes>,
phantom_files: &mut std::collections::HashSet<tranquil_store::blockstore::DataFileId>,
@@ -960,7 +1000,10 @@ fn paginate_repos(
}
pub fn run_reachability_walk(
store: &tranquil_store::blockstore::TranquilBlockStore,
store: &tranquil_store::blockstore::TranquilBlockStore<
tranquil_store::RealIO,
tranquil_store::SystemClock,
>,
repo_repo: &dyn RepoRepository,
) -> anyhow::Result<ReachabilityResult> {
let rt = tokio::runtime::Handle::current();
+4 -1
View File
@@ -471,7 +471,10 @@ impl AppState {
}
struct TranquilStoreWiring {
blockstore: tranquil_store::blockstore::TranquilBlockStore,
blockstore: tranquil_store::blockstore::TranquilBlockStore<
tranquil_store::RealIO,
tranquil_store::SystemClock,
>,
signal_provider: Arc<dyn tranquil_signal::SignalStoreProvider>,
repos: PostgresRepositories,
segments_dir: PathBuf,
+13 -15
View File
@@ -92,30 +92,20 @@ impl CarVerifier {
pub fn verify_car_structure_only(
&self,
expected_did: &Did,
root_cid: &Cid,
blocks: &HashMap<Cid, Bytes>,
) -> Result<VerifiedCar, VerifyError> {
) -> Result<StructureVerifiedCar, VerifyError> {
let root_block = blocks
.get(root_cid)
.ok_or_else(|| VerifyError::BlockNotFound(root_cid.to_string()))?;
let commit =
Commit::from_cbor(root_block).map_err(|e| VerifyError::InvalidCommit(e.to_string()))?;
let commit_did = commit.did().as_str();
if commit_did != expected_did.as_str() {
return Err(VerifyError::DidMismatch {
commit_did: commit_did.to_string(),
expected_did: expected_did.to_string(),
});
}
let commit_did = commit.did().to_string().into();
let data_cid = commit.data();
self.verify_mst_structure(data_cid, blocks)?;
debug!(
"MST structure verified for DID {} (signature verification skipped for migration)",
expected_did
);
Ok(VerifiedCar {
did: expected_did.clone(),
debug!("MST structure verified for commit: {:?}", commit);
Ok(StructureVerifiedCar {
did: commit_did,
rev: commit.rev().to_string(),
data_cid: *data_cid,
prev: commit.prev().cloned(),
@@ -289,6 +279,14 @@ impl CarVerifier {
}
}
#[derive(Debug, Clone)]
pub struct StructureVerifiedCar {
pub did: Did,
pub rev: String,
pub data_cid: Cid,
pub prev: Option<Cid>,
}
#[derive(Debug, Clone)]
pub struct VerifiedCar {
pub did: Did,
+49 -2
View File
@@ -33,7 +33,20 @@ pub fn generate_token_code() -> String {
.map(|_| chars[rng.gen_range(0..chars.len())])
.collect()
};
format!("{}-{}", gen_segment(&mut rng), gen_segment(&mut rng))
// Human-entered short codes are displayed in uppercase; base32 digits are
// unaffected by the conversion.
format!("{}-{}", gen_segment(&mut rng), gen_segment(&mut rng)).to_uppercase()
}
/// Normalize a user-entered short code so that codes are accepted
/// case-insensitively and regardless of the separating hyphen or surrounding
/// whitespace.
pub fn normalize_token_code(input: &str) -> String {
input
.chars()
.filter(|c| !c.is_whitespace() && *c != '-')
.collect::<String>()
.to_uppercase()
}
pub fn parse_repeated_query_param(query: Option<&str>, key: &str) -> Vec<String> {
@@ -491,10 +504,44 @@ mod tests {
assert!(
code.chars()
.filter(|&c| c != '-')
.all(|c| BASE32_ALPHABET.contains(c))
.all(|c| BASE32_ALPHABET.to_uppercase().contains(c))
);
}
#[test]
fn test_generate_token_code_is_uppercase() {
(0..100).for_each(|_| {
let code = generate_token_code();
assert_eq!(code, code.to_uppercase(), "code must be uppercase: {code}");
});
}
#[test]
fn test_normalize_token_code_strips_hyphen_and_uppercases() {
assert_eq!(normalize_token_code("k7m2p-q9rst"), "K7M2PQ9RST");
assert_eq!(normalize_token_code("K7M2P-Q9RST"), "K7M2PQ9RST");
}
#[test]
fn test_normalize_token_code_strips_whitespace() {
assert_eq!(normalize_token_code(" k7m2p-q9rst \n"), "K7M2PQ9RST");
}
#[test]
fn test_normalize_token_code_is_idempotent() {
let once = normalize_token_code("k7m2p-q9rst");
assert_eq!(normalize_token_code(&once), once);
}
#[test]
fn test_generated_code_round_trips_through_normalize() {
let code = generate_token_code();
// A user re-typing the displayed code lowercased and without the hyphen
// must normalize to the same canonical form as the code itself.
let retyped = code.to_lowercase().replace('-', "");
assert_eq!(normalize_token_code(&code), normalize_token_code(&retyped));
}
#[test]
fn test_json_to_ipld_cid_link() {
let json = serde_json::json!({
@@ -0,0 +1,98 @@
use std::sync::Arc;
use cid::Cid;
use jacquard_repo::mst::Mst;
use jacquard_repo::storage::BlockStore;
use tranquil_pds::repo::AnyBlockStore;
use tranquil_pds::scheduled::{RepoCarError, generate_repo_car};
use tranquil_store::blockstore::{BlockStoreConfig, GroupCommitConfig, TranquilBlockStore};
const RECORD_COUNT: usize = 200;
fn open_store(dir: &std::path::Path) -> AnyBlockStore {
let cfg = BlockStoreConfig {
data_dir: dir.join("data"),
index_dir: dir.join("index"),
max_file_size: 64 * 1024,
group_commit: GroupCommitConfig::default(),
shard_count: 1,
};
AnyBlockStore::TranquilStore(TranquilBlockStore::open(cfg).expect("open block store"))
}
async fn build_tree(any: &AnyBlockStore) -> Cid {
let mut mst = Mst::new(Arc::new(any.clone()));
for i in 0..RECORD_COUNT {
let key = format!("app.bsky.feed.post/{i:0>6}");
let cid = any
.put(format!("record body {i}").as_bytes())
.await
.expect("put record");
mst.add_mut(&key, cid).await.expect("mst add");
}
mst.persist().await.expect("persist mst")
}
fn shred_data_files(data_dir: &std::path::Path) {
let mut shredded = false;
for entry in std::fs::read_dir(data_dir).expect("read data dir") {
let path = entry.expect("dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("tqb") {
continue;
}
let mut bytes = std::fs::read(&path).expect("read data file");
let mut off = 5usize;
while off + 48 < bytes.len() {
bytes[off..off + 48].iter_mut().for_each(|b| *b = 0xFF);
off += 192;
shredded = true;
}
std::fs::write(&path, &bytes).expect("write corrupted data file");
}
assert!(shredded, "no .tqb data file was corrupted");
}
#[tokio::test]
async fn car_export_error_is_classified_as_repo_corruption() {
let dir = tempfile::tempdir().expect("tempdir");
let any = open_store(dir.path());
let root = build_tree(&any).await;
generate_repo_car(&any, &root)
.await
.expect("pristine CAR must generate");
shred_data_files(&dir.path().join("data"));
let err = generate_repo_car(&any, &root)
.await
.expect_err("corrupt CAR export must error");
assert!(
err.is_repairable(),
"{err} should classify as repairable so the sync path can self-heal"
);
}
#[tokio::test]
async fn car_export_missing_block_is_repairable() {
let dir = tempfile::tempdir().expect("tempdir");
let source = open_store(dir.path());
let root = build_tree(&source).await;
let pristine = open_store(&dir.path().join("pristine"));
let head_block = source
.get(&root)
.await
.expect("read root")
.expect("root present");
pristine.put(&head_block).await.expect("seed root only");
let err = generate_repo_car(&pristine, &root)
.await
.expect_err("CAR export over a store missing MST children must error");
assert!(
matches!(err, RepoCarError::MissingBlocks(ref cids) if !cids.is_empty()),
"{err} should surface as MissingBlocks when referenced blocks are absent"
);
assert!(err.is_repairable());
}
+50
View File
@@ -30,6 +30,7 @@ static TEST_TEMP_DIR: OnceLock<PathBuf> = OnceLock::new();
static CLUSTER: OnceLock<Vec<ServerInstance>> = OnceLock::new();
static TEST_REPOS: OnceLock<Arc<tranquil_db::PostgresRepositories>> = OnceLock::new();
static TEST_BLOCK_STORE: OnceLock<tranquil_pds::repo::AnyBlockStore> = OnceLock::new();
static TEST_APP_STATE: OnceLock<AppState> = OnceLock::new();
#[allow(dead_code)]
pub fn is_store_backend() -> bool {
@@ -130,6 +131,13 @@ pub fn pds_endpoint() -> String {
format!("https://{}", pds_hostname())
}
#[allow(dead_code)]
pub fn store_data_dir() -> Option<PathBuf> {
std::env::var("TRANQUIL_STORE_DATA_DIR")
.ok()
.map(PathBuf::from)
}
pub async fn base_url() -> &'static str {
SERVER_URL.get_or_init(|| {
let (tx, rx) = std::sync::mpsc::channel();
@@ -579,6 +587,7 @@ async fn spawn_server(config: ServerConfig) -> ServerInstance {
if let Some((cache, distributed_rate_limiter)) = config.cache {
state = state.with_cache(cache, distributed_rate_limiter);
}
TEST_APP_STATE.set(state.clone()).ok();
tranquil_sync::listener::start_sequencer_listener(state.clone()).await;
let app = tranquil_pds::app_with_routes(
state,
@@ -921,6 +930,47 @@ pub async fn get_test_block_store() -> &'static tranquil_pds::repo::AnyBlockStor
.expect("TEST_BLOCK_STORE not initialized")
}
#[allow(dead_code)]
pub async fn get_test_app_state() -> &'static AppState {
base_url().await;
TEST_APP_STATE
.get()
.expect("TEST_APP_STATE not initialized")
}
#[allow(dead_code)]
pub async fn flushed_max_seq(
repos: &tranquil_db::PostgresRepositories,
) -> tranquil_db_traits::SequenceNumber {
repos
.repo
.flush_pending_sequences()
.await
.expect("flush_pending_sequences");
repos.repo.get_max_seq().await.expect("get_max_seq")
}
#[allow(dead_code)]
pub async fn sequenced_event_for_did(
repos: &tranquil_db::PostgresRepositories,
baseline: tranquil_db_traits::SequenceNumber,
did: &tranquil_types::Did,
) -> tranquil_db_traits::SequencedEvent {
repos
.repo
.flush_pending_sequences()
.await
.expect("flush_pending_sequences");
repos
.repo
.get_events_since_seq(baseline, None)
.await
.expect("get_events_since_seq")
.into_iter()
.rfind(|event| &event.did == did)
.unwrap_or_else(|| panic!("event for did {did} not found after flush"))
}
fn extract_verification_code(body_text: &str) -> String {
let lines: Vec<&str> = body_text.lines().collect();
lines
+185
View File
@@ -0,0 +1,185 @@
mod common;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use common::*;
use reqwest::StatusCode;
use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
fn decode_jwt_claims(jwt: &str) -> Value {
let payload = jwt
.split('.')
.nth(1)
.expect("malformed jwt: no claims segment");
let bytes = URL_SAFE_NO_PAD
.decode(payload)
.expect("malformed jwt: claims not base64url");
serde_json::from_slice(&bytes).expect("malformed jwt: claims not json")
}
struct CaptureAuth(Arc<Mutex<Option<String>>>);
impl Respond for CaptureAuth {
fn respond(&self, req: &Request) -> ResponseTemplate {
let auth = req
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
*self.0.lock().unwrap() = auth;
ResponseTemplate::new(200).set_body_json(json!({ "feed": [] }))
}
}
/// getFeed's service-auth token must be audienced to the feed generator, not the AppView.
#[tokio::test]
async fn get_feed_service_auth_is_audienced_to_feed_generator() {
let client = client();
let (token, _did) = create_account_and_login(&client).await;
// One mock server doubles as the AppView: did:web doc, getRecord, and getFeed.
let appview = MockServer::start().await;
let appview_uri = appview.uri();
let host = appview_uri
.strip_prefix("http://")
.expect("mock uri should be http");
// Literal-colon host so did:web resolves over http to the local mock.
let appview_did = format!("did:web:{host}");
Mock::given(method("GET"))
.and(path("/.well-known/did.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": appview_did,
"service": [{
"id": "#bsky_appview",
"type": "BskyAppView",
"serviceEndpoint": appview_uri,
}]
})))
.mount(&appview)
.await;
let feed_did = "did:web:feedgen.example.com";
let feed_uri = "at://did:plc:feedcreator00000000000000/app.bsky.feed.generator/myfeed";
// The feed generator record resolves to its service DID.
Mock::given(method("GET"))
.and(path("/xrpc/com.atproto.repo.getRecord"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"uri": feed_uri,
"value": { "$type": "app.bsky.feed.generator", "did": feed_did },
})))
.mount(&appview)
.await;
// Capture the Authorization header the AppView is handed for getFeed.
let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
Mock::given(method("GET"))
.and(path("/xrpc/app.bsky.feed.getFeed"))
.respond_with(CaptureAuth(captured.clone()))
.mount(&appview)
.await;
// Send the params a real client sends, so feed extraction must ignore extras.
let res = client
.get(format!("{}/xrpc/app.bsky.feed.getFeed", base_url().await))
.query(&[("feed", feed_uri), ("limit", "30"), ("cursor", "abc123")])
.header("authorization", format!("Bearer {}", token))
.header("atproto-proxy", format!("{}#bsky_appview", appview_did))
.send()
.await
.expect("getFeed proxy request failed");
assert_eq!(
res.status(),
StatusCode::OK,
"getFeed proxy should succeed: {:?}",
res.text().await
);
let auth = captured
.lock()
.unwrap()
.clone()
.expect("AppView received no Authorization header");
let jwt = auth
.strip_prefix("Bearer ")
.expect("forwarded auth should be a bearer token");
let claims = decode_jwt_claims(jwt);
assert_eq!(
claims["aud"].as_str(),
Some(feed_did),
"service-auth token must be audienced to the feed generator, got {:?}",
claims["aud"]
);
assert_eq!(
claims["lxm"].as_str(),
Some("app.bsky.feed.getFeedSkeleton"),
"service-auth token lxm must be getFeedSkeleton, got {:?}",
claims["lxm"]
);
}
/// An unresolvable feed generator must be refused, not forwarded with an AppView aud.
#[tokio::test]
async fn get_feed_refuses_when_feed_generator_unresolvable() {
let client = client();
let (token, _did) = create_account_and_login(&client).await;
let appview = MockServer::start().await;
let appview_uri = appview.uri();
let host = appview_uri
.strip_prefix("http://")
.expect("mock uri should be http");
let appview_did = format!("did:web:{host}");
Mock::given(method("GET"))
.and(path("/.well-known/did.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": appview_did,
"service": [{
"id": "#bsky_appview",
"type": "BskyAppView",
"serviceEndpoint": appview_uri,
}]
})))
.mount(&appview)
.await;
// getRecord fails, so the feed generator DID can't be resolved.
Mock::given(method("GET"))
.and(path("/xrpc/com.atproto.repo.getRecord"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({
"error": "RecordNotFound",
})))
.mount(&appview)
.await;
// getFeed must never be reached with an AppView-audienced token.
Mock::given(method("GET"))
.and(path("/xrpc/app.bsky.feed.getFeed"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "feed": [] })))
.expect(0)
.mount(&appview)
.await;
let feed_uri = "at://did:plc:feedcreator00000000000000/app.bsky.feed.generator/myfeed";
let res = client
.get(format!("{}/xrpc/app.bsky.feed.getFeed", base_url().await))
.query(&[("feed", feed_uri)])
.header("authorization", format!("Bearer {token}"))
.header("atproto-proxy", format!("{appview_did}#bsky_appview"))
.send()
.await
.expect("getFeed proxy request failed");
assert_eq!(
res.status(),
StatusCode::BAD_REQUEST,
"unresolvable feed should be rejected, got {}",
res.status()
);
}
@@ -66,6 +66,11 @@ async fn commit_events_carry_inline_blocks() {
let repos = get_test_repos().await;
let typed_did = tranquil_types::Did::new(did.clone()).unwrap();
repos
.repo
.flush_pending_sequences()
.await
.expect("flush_pending_sequences");
let events = repos
.repo
.get_events_since_seq(SequenceNumber::ZERO, None)
@@ -142,18 +147,14 @@ async fn sync_event_carries_inline_commit_block() {
let cid_link: CidLink = (&commit_cid).into();
let rev = "3kabcdefghij2";
let seq = repos
let baseline = repos.repo.get_max_seq().await.expect("get_max_seq");
repos
.repo
.insert_sync_event(&did, &cid_link, Some(rev), &commit_bytes)
.await
.expect("insert_sync_event");
let event = repos
.repo
.get_event_by_seq(seq)
.await
.expect("get_event_by_seq")
.expect("event present");
let event = sequenced_event_for_did(repos, baseline, &did).await;
assert_eq!(event.event_type, RepoEventType::Sync);
let blocks = event
@@ -193,7 +194,8 @@ async fn genesis_commit_event_carries_inline_blocks() {
let mst_link: CidLink = (&mst_root_cid).into();
let rev = "3kabcdefghij3";
let seq = repos
let baseline = repos.repo.get_max_seq().await.expect("get_max_seq");
repos
.repo
.insert_genesis_commit_event(
&did,
@@ -206,12 +208,7 @@ async fn genesis_commit_event_carries_inline_blocks() {
.await
.expect("insert_genesis_commit_event");
let event = repos
.repo
.get_event_by_seq(seq)
.await
.expect("get_event_by_seq")
.expect("event present");
let event = sequenced_event_for_did(repos, baseline, &did).await;
assert_eq!(event.event_type, RepoEventType::Commit);
let blocks = event
@@ -329,6 +326,11 @@ async fn import_event_carries_inline_commit_block() {
let repos = get_test_repos().await;
let typed_did = tranquil_types::Did::new(did.clone()).unwrap();
repos
.repo
.flush_pending_sequences()
.await
.expect("flush_pending_sequences");
let events = repos
.repo
.get_events_since_seq(SequenceNumber::ZERO, None)
@@ -801,7 +801,7 @@ async fn test_firehose_outdated_cursor_info() {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let repos = get_test_repos().await;
let max_seq = repos.repo.get_max_seq().await.unwrap().as_i64();
let max_seq = flushed_max_seq(repos).await.as_i64();
let outdated_cursor = (max_seq - 100).max(1);
let url = format!(
"ws://127.0.0.1:{}/xrpc/com.atproto.sync.subscribeRepos?cursor={}",
@@ -5,7 +5,12 @@ use common::*;
use reqwest::StatusCode;
use serde_json::{Value, json};
fn run_compaction(store: &tranquil_store::blockstore::TranquilBlockStore) {
fn run_compaction(
store: &tranquil_store::blockstore::TranquilBlockStore<
tranquil_store::RealIO,
tranquil_store::SystemClock,
>,
) {
let liveness = store.compaction_liveness(0).unwrap();
liveness
.iter()
@@ -73,7 +73,7 @@ fn write_varint(buf: &mut Vec<u8>, mut value: u64) {
}
#[tokio::test]
async fn test_import_rejects_car_for_different_user() {
async fn test_import_doesnt_reject_car_for_different_user() {
let client = client();
let (token_a, _did_a) = create_account_and_login(&client).await;
let (_token_b, did_b) = create_account_and_login(&client).await;
@@ -99,15 +99,9 @@ async fn test_import_rejects_car_for_different_user() {
.send()
.await
.expect("Import failed");
assert_eq!(import_res.status(), StatusCode::FORBIDDEN);
assert_eq!(import_res.status(), StatusCode::OK);
let body: serde_json::Value = import_res.json().await.unwrap();
assert!(
body["error"] == "InvalidRepo"
|| body["error"] == "InvalidRequest"
|| body["error"] == "DidMismatch",
"Expected InvalidRepo, DidMismatch, or InvalidRequest error, got: {:?}",
body
);
assert!(body.is_object() && body.as_object().unwrap().is_empty());
}
#[tokio::test]
+10 -12
View File
@@ -40,19 +40,17 @@ async fn get_2fa_code_from_queue(did: &str) -> Option<String> {
.await
.ok()?;
const ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
comms.first().and_then(|c| {
c.body
.lines()
.find(|line: &&str| line.chars().all(|c: char| c.is_ascii_digit()) && line.len() == 8)
.map(|s: &str| s.to_string())
.or_else(|| {
c.body
.split_whitespace()
.find(|word: &&str| {
word.chars().all(|c: char| c.is_ascii_digit()) && word.len() == 8
})
.map(|s: &str| s.to_string())
})
c.body.split_whitespace().find_map(|word: &str| {
let candidate = word.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-');
let normalized = candidate.replace('-', "");
if normalized.len() == 10 && normalized.chars().all(|ch| ALPHABET.contains(ch)) {
Some(candidate.to_string())
} else {
None
}
})
})
}
@@ -773,3 +773,166 @@ async fn test_list_records_comprehensive() {
.expect("Failed with nonexistent repo");
assert_eq!(not_found_res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_missing_type_is_filled_from_collection() {
let client = client();
let (did, jwt) = setup_new_user("missing-type").await;
let now = Utc::now().to_rfc3339();
let create_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"repo": did,
"collection": "app.bsky.feed.post",
"record": { "text": "no type set", "createdAt": now }
}))
.send()
.await
.expect("Failed to create record without $type");
assert_eq!(
create_res.status(),
StatusCode::OK,
"createRecord should fill missing $type from collection"
);
let create_body: Value = create_res.json().await.unwrap();
let create_rkey = create_body["uri"]
.as_str()
.unwrap()
.rsplit('/')
.next()
.unwrap()
.to_string();
let get_created = client
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkey", &create_rkey),
])
.send()
.await
.expect("Failed to get created record");
let created_body: Value = get_created.json().await.unwrap();
assert_eq!(created_body["value"]["$type"], "app.bsky.feed.post");
let put_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"repo": did,
"collection": "app.bsky.actor.profile",
"rkey": "self",
"record": { "displayName": "No Type" }
}))
.send()
.await
.expect("Failed to put record without $type");
assert_eq!(
put_res.status(),
StatusCode::OK,
"putRecord should fill missing $type from collection"
);
let get_put = client
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.actor.profile"),
("rkey", "self"),
])
.send()
.await
.expect("Failed to get put record");
let put_body: Value = get_put.json().await.unwrap();
assert_eq!(put_body["value"]["$type"], "app.bsky.actor.profile");
let apply_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.applyWrites",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"repo": did,
"writes": [
{ "$type": "com.atproto.repo.applyWrites#create", "collection": "app.bsky.feed.post", "rkey": "batch-no-type", "value": { "text": "batch no type", "createdAt": now } }
]
}))
.send()
.await
.expect("Failed to apply writes without $type");
assert_eq!(
apply_res.status(),
StatusCode::OK,
"applyWrites should fill missing $type from collection"
);
let get_batch = client
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkey", "batch-no-type"),
])
.send()
.await
.expect("Failed to get batch record");
let batch_body: Value = get_batch.json().await.unwrap();
assert_eq!(batch_body["value"]["$type"], "app.bsky.feed.post");
let mismatch_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"repo": did,
"collection": "app.bsky.feed.post",
"record": { "$type": "app.bsky.feed.like", "text": "wrong type", "createdAt": now }
}))
.send()
.await
.expect("Failed to send mismatch request");
assert_eq!(
mismatch_res.status(),
StatusCode::BAD_REQUEST,
"explicit mismatched $type should still be rejected"
);
let non_string_type_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"repo": did,
"collection": "app.bsky.feed.post",
"record": { "$type": 123, "text": "non-string type", "createdAt": now }
}))
.send()
.await
.expect("Failed to send non-string type request");
assert_eq!(
non_string_type_res.status(),
StatusCode::BAD_REQUEST,
"present non-string $type should be rejected, not overwritten"
);
}
@@ -223,7 +223,7 @@ async fn websocket_firehose_frames_pass_inductive_forward_and_inverse() {
let (token, did) = create_account_and_login(&client).await;
let repos = get_test_repos().await;
let cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
let cursor = flushed_max_seq(repos).await.as_i64();
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
tokio::time::sleep(Duration::from_millis(100)).await;
@@ -298,7 +298,7 @@ async fn websocket_firehose_car_root_matches_commit_cid() {
let (token, did) = create_account_and_login(&client).await;
let repos = get_test_repos().await;
let cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
let cursor = flushed_max_seq(repos).await.as_i64();
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
tokio::time::sleep(Duration::from_millis(100)).await;
@@ -340,7 +340,7 @@ async fn websocket_firehose_resumption_from_cursor_yields_valid_frames() {
create_record(&client, &token, &did, &rkey_for(i), "pre").await;
}
let resume_cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
let resume_cursor = flushed_max_seq(repos).await.as_i64();
for i in 5..12 {
create_record(&client, &token, &did, &rkey_for(i), "post").await;
@@ -371,7 +371,7 @@ async fn websocket_firehose_ops_include_prev_field_for_update_delete() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let repos = get_test_repos().await;
let cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
let cursor = flushed_max_seq(repos).await.as_i64();
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
tokio::time::sleep(Duration::from_millis(100)).await;
@@ -417,7 +417,7 @@ async fn websocket_firehose_rebuild_new_mst_from_car_matches_commit_data() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let repos = get_test_repos().await;
let cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
let cursor = flushed_max_seq(repos).await.as_i64();
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
tokio::time::sleep(Duration::from_millis(100)).await;
@@ -247,6 +247,11 @@ fn rkey_for(prefix: &str, i: usize) -> String {
async fn our_commit_events(did: &str) -> Vec<SequencedEvent> {
let repos = get_test_repos().await;
let typed_did = Did::new(did.to_string()).unwrap();
repos
.repo
.flush_pending_sequences()
.await
.expect("flush_pending_sequences");
let events = repos
.repo
.get_events_since_seq(SequenceNumber::ZERO, None)
+7 -2
View File
@@ -216,6 +216,11 @@ async fn imported_repo_emits_commit_event_with_valid_car() {
let repos = get_test_repos().await;
let typed_did = Did::new(did.clone()).unwrap();
repos
.repo
.flush_pending_sequences()
.await
.expect("flush_pending_sequences");
let events = repos
.repo
.get_events_since_seq(SequenceNumber::ZERO, None)
@@ -253,7 +258,7 @@ async fn firehose_commit_block_bytes_roundtrip_to_same_cid() {
let (token, did) = create_account_and_login(&client).await;
let repos = get_test_repos().await;
let cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
let cursor = flushed_max_seq(repos).await.as_i64();
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
tokio::time::sleep(Duration::from_millis(100)).await;
@@ -284,7 +289,7 @@ async fn firehose_commit_car_contains_new_record_bytes_for_every_create() {
let (token, did) = create_account_and_login(&client).await;
let repos = get_test_repos().await;
let cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
let cursor = flushed_max_seq(repos).await.as_i64();
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
tokio::time::sleep(Duration::from_millis(100)).await;
@@ -267,6 +267,11 @@ async fn fuzz_run_with_seed(seed: u64, steps: usize) -> Vec<String> {
let repos = get_test_repos().await;
let typed_did = Did::new(did.clone()).unwrap();
repos
.repo
.flush_pending_sequences()
.await
.expect("flush_pending_sequences");
let events = repos
.repo
.get_events_since_seq(SequenceNumber::ZERO, None)
@@ -0,0 +1,185 @@
use std::path::Path;
use std::sync::Arc;
use cid::Cid;
use jacquard_repo::mst::Mst;
use jacquard_repo::storage::BlockStore;
use tranquil_pds::api::error::ApiError;
use tranquil_pds::repo::AnyBlockStore;
use tranquil_store::blockstore::{
BLOCK_HEADER_SIZE, BlockStoreConfig, CID_SIZE, GroupCommitConfig, TranquilBlockStore,
};
const RECORD_COUNT: usize = 300;
fn open_store(dir: &Path) -> AnyBlockStore {
let cfg = BlockStoreConfig {
data_dir: dir.join("data"),
index_dir: dir.join("index"),
max_file_size: 64 * 1024,
group_commit: GroupCommitConfig::default(),
shard_count: 1,
};
AnyBlockStore::TranquilStore(TranquilBlockStore::open(cfg).expect("open block store"))
}
async fn build_repo(any: &AnyBlockStore) -> (Cid, Vec<(String, Cid)>) {
let mut mst = Mst::new(Arc::new(any.clone()));
let mut entries: Vec<(String, Cid)> = Vec::with_capacity(RECORD_COUNT);
for i in 0..RECORD_COUNT {
let key = format!("app.bsky.feed.post/{i:0>6}");
let body = format!("record body number {i}").into_bytes();
let cid = any.put(&body).await.expect("put record");
mst.add_mut(&key, cid).await.expect("mst add");
entries.push((key, cid));
}
let data_root = mst.persist().await.expect("persist mst");
(data_root, entries)
}
fn shred_data_files(data_dir: &Path) {
let mut shredded = false;
for entry in std::fs::read_dir(data_dir).expect("read data dir") {
let path = entry.expect("dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("tqb") {
continue;
}
let mut bytes = std::fs::read(&path).expect("read data file");
let mut off = 5usize;
while off + 48 < bytes.len() {
bytes[off..off + 48].iter_mut().for_each(|b| *b = 0xFF);
off += 192;
shredded = true;
}
std::fs::write(&path, &bytes).expect("write corrupted data file");
}
assert!(shredded, "no .tqb data file was corrupted");
}
fn corrupt_block_with_cid(data_dir: &Path, target: &[u8]) -> bool {
for entry in std::fs::read_dir(data_dir).expect("read data dir") {
let path = entry.expect("dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("tqb") {
continue;
}
let mut bytes = std::fs::read(&path).expect("read data file");
let mut pos = BLOCK_HEADER_SIZE;
while pos + CID_SIZE + 4 <= bytes.len() {
let cid = bytes[pos..pos + CID_SIZE].to_vec();
let len = u32::from_le_bytes(
bytes[pos + CID_SIZE..pos + CID_SIZE + 4]
.try_into()
.unwrap(),
) as usize;
let data_start = pos + CID_SIZE + 4;
let rec_end = data_start + len + 4;
if rec_end > bytes.len() {
break;
}
if cid.as_slice() == target && len > 0 {
bytes[data_start] ^= 0xFF;
std::fs::write(&path, &bytes).expect("write corrupted data file");
return true;
}
pos = rec_end;
}
}
false
}
#[tokio::test]
async fn corrupt_mst_node_classifies_as_repo_corruption() {
let dir = tempfile::tempdir().expect("tempdir");
let any = open_store(dir.path());
let (root, entries) = build_repo(&any).await;
shred_data_files(&dir.path().join("data"));
let mst = Mst::load(Arc::new(any.clone()), root, None);
let mut classified_corruption = false;
for (key, _) in &entries {
if let Err(e) = mst.get(key).await {
assert!(
ApiError::from_mst_error("audit", &e).is_repo_corruption(),
"corrupt MST node must classify as RepoCorruption via from_mst_error; raw error: {e}"
);
assert!(
ApiError::detail_is_repo_corruption(&e.to_string()),
"to_string of corrupt-node error must carry the marker; raw error: {e}"
);
classified_corruption = true;
break;
}
}
assert!(
classified_corruption,
"shredded tree must produce at least one corrupt-node read error"
);
}
#[tokio::test]
async fn missing_mst_node_classifies_as_repo_corruption() {
let dir = tempfile::tempdir().expect("tempdir");
let any = open_store(dir.path());
let (root, entries) = build_repo(&any).await;
let empty_dir = tempfile::tempdir().expect("empty tempdir");
let empty = open_store(empty_dir.path());
let mst = Mst::load(Arc::new(empty.clone()), root, None);
let err = mst
.get(&entries[0].0)
.await
.expect_err("loading a root absent from the store must error");
assert!(
ApiError::from_mst_error("audit", &err).is_repo_corruption(),
"a missing MST node must classify as repairable so self-heal triggers; raw error: {err}"
);
assert!(
ApiError::detail_is_repo_corruption(&format!("{err:#}")),
"missing-node error must carry a repairable marker; raw error: {err}"
);
}
#[tokio::test]
async fn leaf_block_corruption_is_not_repaired_by_structural_repair() {
let dir = tempfile::tempdir().expect("tempdir");
let any = open_store(dir.path());
let (root, entries) = build_repo(&any).await;
let (_, rec_cid) = &entries[0];
assert!(
any.get(rec_cid).await.expect("read leaf").is_some(),
"leaf must be readable before corruption"
);
let target = rec_cid.to_bytes();
assert!(
corrupt_block_with_cid(&dir.path().join("data"), &target),
"must locate the record leaf block to corrupt"
);
let read_err = any
.get(rec_cid)
.await
.expect_err("corrupt leaf must fail to read");
assert!(
ApiError::detail_is_repo_corruption(&read_err.to_string()),
"corrupt leaf read error must carry the marker; raw error: {read_err}"
);
let outcome = any
.repair_structure(&entries, root)
.await
.expect("structural repair must succeed");
assert_eq!(
outcome.nodes_repaired, 0,
"structural repair only touches MST nodes, so a leaf-only corruption yields zero repairs"
);
assert!(
any.get(rec_cid).await.is_err(),
"leaf corruption is NOT healed by structural repair"
);
}
@@ -0,0 +1,152 @@
use std::sync::Arc;
use cid::Cid;
use jacquard_repo::mst::Mst;
use jacquard_repo::storage::BlockStore;
use tranquil_pds::repo::AnyBlockStore;
use tranquil_store::blockstore::{BlockStoreConfig, GroupCommitConfig, TranquilBlockStore};
const RECORD_COUNT: usize = 300;
fn open_store(dir: &std::path::Path) -> AnyBlockStore {
let cfg = BlockStoreConfig {
data_dir: dir.join("data"),
index_dir: dir.join("index"),
max_file_size: 64 * 1024,
group_commit: GroupCommitConfig::default(),
shard_count: 1,
};
AnyBlockStore::TranquilStore(TranquilBlockStore::open(cfg).expect("open block store"))
}
async fn build_repo(any: &AnyBlockStore) -> (Cid, Vec<(String, Cid)>) {
let mut mst = Mst::new(Arc::new(any.clone()));
let mut entries: Vec<(String, Cid)> = Vec::with_capacity(RECORD_COUNT);
for i in 0..RECORD_COUNT {
let key = format!("app.bsky.feed.post/{i:0>6}");
let body = format!("record body number {i}").into_bytes();
let cid = any.put(&body).await.expect("put record");
mst.add_mut(&key, cid).await.expect("mst add");
entries.push((key, cid));
}
let data_root = mst.persist().await.expect("persist mst");
(data_root, entries)
}
fn shred_data_files(data_dir: &std::path::Path) {
let mut shredded = false;
for entry in std::fs::read_dir(data_dir).expect("read data dir") {
let path = entry.expect("dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("tqb") {
continue;
}
let mut bytes = std::fs::read(&path).expect("read data file");
let mut off = 5usize;
while off + 48 < bytes.len() {
bytes[off..off + 48].iter_mut().for_each(|b| *b = 0xFF);
off += 192;
shredded = true;
}
std::fs::write(&path, &bytes).expect("write corrupted data file");
}
assert!(shredded, "no .tqb data file was corrupted");
}
async fn walk_all(
any: &AnyBlockStore,
root: Cid,
entries: &[(String, Cid)],
) -> Result<usize, String> {
let mst = Mst::load(Arc::new(any.clone()), root, None);
let mut resolved = 0usize;
for (key, expected) in entries {
match mst.get(key).await {
Ok(Some(cid)) if cid == *expected => resolved += 1,
Ok(Some(cid)) => {
return Err(format!("{key}: resolved to {cid} != expected {expected}"));
}
Ok(None) => return Err(format!("{key}: missing")),
Err(e) => return Err(format!("{key}: read error {e}")),
}
}
Ok(resolved)
}
fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) {
std::fs::create_dir_all(dst).expect("create dst dir");
for entry in std::fs::read_dir(src).expect("read src dir") {
let entry = entry.expect("dir entry");
let from = entry.path();
let to = dst.join(entry.file_name());
if from.is_dir() {
copy_dir_recursive(&from, &to);
} else {
std::fs::copy(&from, &to).expect("copy file");
}
}
}
#[tokio::test]
async fn pristine_repo_survives_copy_and_reopen() {
let dir = tempfile::tempdir().expect("tempdir");
let any = open_store(dir.path());
let (data_root, entries) = build_repo(&any).await;
assert_eq!(
walk_all(&any, data_root, &entries).await.expect("pristine"),
RECORD_COUNT
);
let snap = tempfile::tempdir().expect("snap tempdir");
copy_dir_recursive(dir.path(), snap.path());
let reopened = open_store(snap.path());
assert_eq!(
walk_all(&reopened, data_root, &entries)
.await
.expect("pristine repo must survive copy+reopen"),
RECORD_COUNT
);
}
#[tokio::test]
async fn repair_survives_crash_and_reopen() {
let dir = tempfile::tempdir().expect("tempdir");
let any = open_store(dir.path());
let (data_root, entries) = build_repo(&any).await;
assert_eq!(
walk_all(&any, data_root, &entries).await.expect("pristine"),
RECORD_COUNT
);
shred_data_files(&dir.path().join("data"));
assert!(
walk_all(&any, data_root, &entries).await.is_err(),
"corruption must break the walk"
);
let outcome = any
.repair_structure(&entries, data_root)
.await
.expect("repair_structure");
assert!(outcome.nodes_repaired > 0, "repair must rewrite a node");
assert_eq!(
walk_all(&any, data_root, &entries)
.await
.expect("in-process walk after repair"),
RECORD_COUNT
);
let snap = tempfile::tempdir().expect("snap tempdir");
copy_dir_recursive(dir.path(), snap.path());
let reopened = open_store(snap.path());
assert_eq!(
walk_all(&reopened, data_root, &entries)
.await
.expect("every key must resolve after crash-recovery of a repair"),
RECORD_COUNT,
"repair did not survive reopen"
);
}
@@ -0,0 +1,151 @@
mod common;
use cid::Cid;
use common::{base_url, client, create_account_and_login, store_data_dir};
use reqwest::StatusCode;
use serde_json::{Value, json};
use std::str::FromStr;
use tranquil_store::blockstore::{BLOCK_HEADER_SIZE, CID_SIZE};
#[ctor::ctor]
fn force_store_backend() {
unsafe {
std::env::set_var("TRANQUIL_TEST_BACKEND", "store");
}
}
const COLLECTION: &str = "app.bsky.feed.post";
fn post_record(i: usize) -> Value {
json!({
"$type": COLLECTION,
"text": format!("self-heal record {i}"),
"createdAt": "2024-01-01T00:00:00.000Z"
})
}
async fn apply_creates(token: &str, did: &str, start: usize, count: usize) {
let writes: Vec<Value> = (start..start + count)
.map(|i| {
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": COLLECTION,
"rkey": format!("selfheal{i:05}"),
"value": post_record(i)
})
})
.collect();
let res = client()
.post(format!(
"{}/xrpc/com.atproto.repo.applyWrites",
base_url().await
))
.bearer_auth(token)
.json(&json!({ "repo": did, "validate": false, "writes": writes }))
.send()
.await
.expect("applyWrites send");
assert_eq!(
res.status(),
StatusCode::OK,
"applyWrites failed: {:?}",
res.text().await
);
}
async fn latest_commit_cid(did: &str) -> Cid {
let res = client()
.get(format!(
"{}/xrpc/com.atproto.sync.getLatestCommit?did={did}",
base_url().await
))
.send()
.await
.expect("getLatestCommit send");
assert_eq!(res.status(), StatusCode::OK, "getLatestCommit failed");
let body: Value = res.json().await.expect("getLatestCommit json");
Cid::from_str(body["cid"].as_str().expect("commit cid")).expect("parse commit cid")
}
fn collect_tqb(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_tqb(&path, out);
} else if path.extension().and_then(|e| e.to_str()) == Some("tqb") {
out.push(path);
}
}
}
fn corrupt_every_block_except(data_dir: &std::path::Path, keep: &[u8]) -> usize {
let mut corrupted = 0usize;
let mut files = Vec::new();
collect_tqb(data_dir, &mut files);
for path in files {
let mut bytes = std::fs::read(&path).expect("read tqb");
let mut pos = BLOCK_HEADER_SIZE;
while pos + CID_SIZE + 4 <= bytes.len() {
let cid = &bytes[pos..pos + CID_SIZE];
let len = u32::from_le_bytes(
bytes[pos + CID_SIZE..pos + CID_SIZE + 4]
.try_into()
.unwrap(),
) as usize;
let data_start = pos + CID_SIZE + 4;
let rec_end = data_start + len + 4;
if rec_end > bytes.len() {
break;
}
if cid != keep && len > 0 {
bytes[data_start] ^= 0xFF;
corrupted += 1;
}
pos = rec_end;
}
std::fs::write(&path, &bytes).expect("write corrupted tqb");
}
corrupted
}
#[tokio::test]
async fn write_self_heals_after_mst_node_corruption() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
apply_creates(&token, &did, 0, 150).await;
apply_creates(&token, &did, 150, 150).await;
let commit_cid = latest_commit_cid(&did).await;
let commit_bytes = commit_cid.to_bytes();
let data_dir = store_data_dir().expect("store backend data dir");
let corrupted = corrupt_every_block_except(&data_dir, &commit_bytes);
assert!(corrupted > 0, "expected to corrupt committed blocks");
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.bearer_auth(&token)
.json(&json!({
"repo": did,
"collection": COLLECTION,
"validate": false,
"record": post_record(9999)
}))
.send()
.await
.expect("createRecord send");
assert_eq!(
res.status(),
StatusCode::OK,
"write should self-heal corrupted MST and succeed: {:?}",
res.text().await
);
}
@@ -0,0 +1,108 @@
use std::sync::Arc;
use cid::Cid;
use jacquard_repo::mst::Mst;
use jacquard_repo::storage::BlockStore;
use tranquil_pds::repo::AnyBlockStore;
use tranquil_store::blockstore::{BlockStoreConfig, GroupCommitConfig, TranquilBlockStore};
const RECORD_COUNT: usize = 300;
fn open_store(dir: &std::path::Path) -> AnyBlockStore {
let cfg = BlockStoreConfig {
data_dir: dir.join("data"),
index_dir: dir.join("index"),
max_file_size: 64 * 1024,
group_commit: GroupCommitConfig::default(),
shard_count: 1,
};
AnyBlockStore::TranquilStore(TranquilBlockStore::open(cfg).expect("open block store"))
}
async fn build_repo(any: &AnyBlockStore) -> (Cid, Vec<(String, Cid)>) {
let mut mst = Mst::new(Arc::new(any.clone()));
let mut entries: Vec<(String, Cid)> = Vec::with_capacity(RECORD_COUNT);
for i in 0..RECORD_COUNT {
let key = format!("app.bsky.feed.post/{i:0>6}");
let body = format!("record body number {i}").into_bytes();
let cid = any.put(&body).await.expect("put record");
mst.add_mut(&key, cid).await.expect("mst add");
entries.push((key, cid));
}
let data_root = mst.persist().await.expect("persist mst");
(data_root, entries)
}
fn shred_data_files(data_dir: &std::path::Path) {
let mut shredded = false;
for entry in std::fs::read_dir(data_dir).expect("read data dir") {
let path = entry.expect("dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("tqb") {
continue;
}
let mut bytes = std::fs::read(&path).expect("read data file");
let mut off = 5usize;
while off + 48 < bytes.len() {
bytes[off..off + 48].iter_mut().for_each(|b| *b = 0xFF);
off += 192;
shredded = true;
}
std::fs::write(&path, &bytes).expect("write corrupted data file");
}
assert!(shredded, "no .tqb data file was corrupted");
}
async fn walk_all(
any: &AnyBlockStore,
root: Cid,
entries: &[(String, Cid)],
) -> Result<usize, String> {
let mst = Mst::load(Arc::new(any.clone()), root, None);
let mut resolved = 0usize;
for (key, expected) in entries {
match mst.get(key).await {
Ok(Some(cid)) if cid == *expected => resolved += 1,
Ok(Some(cid)) => {
return Err(format!("{key}: resolved to {cid} != expected {expected}"));
}
Ok(None) => return Err(format!("{key}: missing")),
Err(e) => return Err(format!("{key}: read error {e}")),
}
}
Ok(resolved)
}
#[tokio::test]
async fn repair_restores_mst_after_node_corruption() {
let dir = tempfile::tempdir().expect("tempdir");
let any = open_store(dir.path());
let (data_root, entries) = build_repo(&any).await;
let resolved = walk_all(&any, data_root, &entries)
.await
.expect("pristine tree must resolve every key");
assert_eq!(resolved, RECORD_COUNT);
shred_data_files(&dir.path().join("data"));
let broken = walk_all(&any, data_root, &entries).await;
assert!(
broken.is_err(),
"corruption must break the MST walk, got {broken:?}"
);
let outcome = any
.repair_structure(&entries, data_root)
.await
.expect("repair_structure");
assert!(
outcome.nodes_repaired > 0,
"repair must rewrite at least one node, got {outcome:?}"
);
let resolved = walk_all(&any, data_root, &entries)
.await
.expect("every key must resolve after repair");
assert_eq!(resolved, RECORD_COUNT);
}
+9 -3
View File
@@ -45,9 +45,12 @@ async fn test_request_password_reset_creates_code() {
.expect("user not found");
assert!(info.code.is_some());
assert!(info.expires_at.is_some());
// The stored code is normalized: uppercase base32, 10 chars, no hyphen.
// The hyphenated display form only appears in the email.
let code = info.code.unwrap();
assert!(code.contains('-'));
assert_eq!(code.len(), 11);
assert!(!code.contains('-'));
assert_eq!(code.len(), 10);
assert_eq!(code, code.to_uppercase());
}
#[tokio::test]
@@ -109,7 +112,10 @@ async fn test_reset_password_with_valid_token() {
.await
.expect("failed to look up user")
.expect("user not found");
let token = info.code.expect("No reset code");
let stored = info.code.expect("No reset code");
// Submit a variant a user might actually type: lowercased, with the display
// hyphen re-inserted. Normalization must still accept it.
let token = format!("{}-{}", &stored[0..5], &stored[5..10]).to_lowercase();
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.resetPassword",
+13 -3
View File
@@ -188,12 +188,22 @@ async fn test_plc_token_lifecycle() {
"PLC token should be created in database"
);
let first = &tokens[0];
// The token is persisted in canonical (normalized) form: uppercase base32,
// 10 chars, no hyphen. The hyphenated display form only appears in the email.
assert_eq!(
first.token.len(),
11,
"Token should be in format xxxxx-xxxxx"
10,
"Stored token should be the 10-char canonical form"
);
assert!(
!first.token.contains('-'),
"Stored token should not contain a hyphen"
);
assert_eq!(
first.token,
first.token.to_uppercase(),
"Stored token should be uppercase"
);
assert!(first.token.contains('-'), "Token should contain hyphen");
assert!(
first.expires_at > chrono::Utc::now(),
"Token should not be expired"
@@ -0,0 +1,154 @@
mod common;
mod helpers;
use cid::Cid;
use common::*;
use helpers::*;
use jacquard_repo::commit::Commit;
use jacquard_repo::storage::BlockStore;
use serde_json::json;
use std::str::FromStr;
use tranquil_types::Did;
#[tokio::test]
async fn repair_fails_loud_on_missing_leaf_block() {
let client = client();
let repos = get_test_repos().await;
let block_store = get_test_block_store().await;
let state = get_test_app_state().await;
let Some(pg) = block_store.as_postgres() else {
eprintln!(
"repair_fails_loud_on_missing_leaf_block: requires postgres backend, skipping under store backend"
);
return;
};
let pool = pg.pool();
let (did, jwt) = setup_new_user("repair-leaf-loss").await;
let writes: Vec<serde_json::Value> = (0..6)
.map(|i| {
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": "app.bsky.feed.post",
"rkey": format!("leafloss{i:05}"),
"value": {
"$type": "app.bsky.feed.post",
"text": format!("repair leaf loss {i}"),
"createdAt": "2026-01-01T00:00:00.000Z"
}
})
})
.collect();
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.applyWrites",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({ "repo": did, "validate": false, "writes": writes }))
.send()
.await
.expect("applyWrites send");
assert_eq!(
res.status(),
reqwest::StatusCode::OK,
"applyWrites failed: {:?}",
res.text().await
);
let user_id = repos
.user
.get_id_by_did(&Did::new(did.clone()).unwrap())
.await
.expect("DB error")
.expect("user not found");
let root_str = repos
.repo
.get_repo_root_cid_by_user_id(user_id)
.await
.expect("DB error")
.expect("repo root not found");
let commit_cid = Cid::from_str(&root_str).expect("parse commit cid");
let commit_bytes = block_store
.get(&commit_cid)
.await
.expect("read commit")
.expect("commit present");
let mst_root_cid = Commit::from_cbor(&commit_bytes).expect("parse commit").data;
let records = repos
.repo
.get_all_records(user_id)
.await
.expect("get_all_records");
assert!(!records.is_empty(), "repo must contain records");
let leaf_cid = Cid::from_str(records[0].record_cid.as_str()).expect("parse leaf cid");
assert!(
block_store
.get(&leaf_cid)
.await
.expect("read leaf")
.is_some(),
"leaf must be present before corruption"
);
repos
.repo
.delete_user_blocks(user_id, &[leaf_cid.to_bytes()])
.await
.expect("clear leaf user_blocks row");
sqlx::query("DELETE FROM blocks WHERE cid = $1")
.bind(mst_root_cid.to_bytes())
.execute(pool)
.await
.expect("delete mst root node block");
sqlx::query("DELETE FROM blocks WHERE cid = $1")
.bind(leaf_cid.to_bytes())
.execute(pool)
.await
.expect("delete leaf record block");
assert!(
block_store
.get(&mst_root_cid)
.await
.expect("read")
.is_none(),
"mst root node must be gone to force a structural repair"
);
assert!(
block_store.get(&leaf_cid).await.expect("read").is_none(),
"leaf block must be gone to simulate data loss"
);
let err = tranquil_pds::repo_ops::repair_repo_structure(state, user_id)
.await
.expect_err("repair must fail loud when a leaf block is unrecoverable");
let detail = format!("{err:?}");
assert!(
detail.contains("leaf data loss"),
"expected an unrecoverable-leaf-loss error, got: {detail}"
);
assert!(
block_store
.get(&mst_root_cid)
.await
.expect("read")
.is_some(),
"structural repair must still re-insert the regenerable MST node"
);
let recorded = repos
.repo
.get_user_block_cids_since_rev(user_id, "")
.await
.expect("read user_blocks");
assert!(
!recorded.contains(&leaf_cid.to_bytes()),
"missing leaf must not be phantom-inserted into user_blocks"
);
}
+8 -8
View File
@@ -59,7 +59,7 @@ async fn test_create_record_cid_matches_firehose() {
let (token, did) = create_account_and_login(&client).await;
let repos = get_test_repos().await;
let cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
let cursor = flushed_max_seq(repos).await.as_i64();
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
@@ -134,7 +134,7 @@ async fn test_update_record_prev_matches_old_cid() {
let v1_cid = Cid::from_str(v1_cid_str).unwrap();
let repos = get_test_repos().await;
let cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
let cursor = flushed_max_seq(repos).await.as_i64();
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
@@ -203,7 +203,7 @@ async fn test_delete_record_prev_set_cid_none() {
let rkey = parts[parts.len() - 1];
let repos = get_test_repos().await;
let cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
let cursor = flushed_max_seq(repos).await.as_i64();
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
@@ -246,7 +246,7 @@ async fn test_five_record_commit_chain_integrity() {
let (token, did) = create_account_and_login(&client).await;
let repos = get_test_repos().await;
let cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
let cursor = flushed_max_seq(repos).await.as_i64();
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
@@ -315,7 +315,7 @@ async fn test_apply_writes_single_commit_multiple_ops() {
let (token, did) = create_account_and_login(&client).await;
let repos = get_test_repos().await;
let cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
let cursor = flushed_max_seq(repos).await.as_i64();
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
@@ -396,7 +396,7 @@ async fn test_firehose_commit_signature_verification() {
};
let repos = get_test_repos().await;
let cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
let cursor = flushed_max_seq(repos).await.as_i64();
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
@@ -444,7 +444,7 @@ async fn test_cursor_backfill_completeness() {
let (token, did) = create_account_and_login(&client).await;
let repos = get_test_repos().await;
let baseline_seq = repos.repo.get_max_seq().await.unwrap().as_i64();
let baseline_seq = flushed_max_seq(repos).await.as_i64();
let mut expected_cids: Vec<String> = Vec::with_capacity(5);
let texts = [
@@ -496,7 +496,7 @@ async fn test_multi_account_seq_interleaving() {
let (bob_token, bob_did) = create_account_and_login(&client).await;
let repos = get_test_repos().await;
let cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
let cursor = flushed_max_seq(repos).await.as_i64();
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+6 -4
View File
@@ -1628,10 +1628,12 @@ async fn parity_prune_events_older_than() {
rev: Some("rev0".to_string()),
};
let pg_seq = f.pg.repo.insert_commit_event(&event).await.unwrap();
let store_seq = f.store.repo.insert_commit_event(&event).await.unwrap();
assert!(pg_seq.as_i64() > 0);
assert!(store_seq.as_i64() > 0);
let baseline = f.pg.repo.get_max_seq().await.unwrap();
f.pg.repo.insert_commit_event(&event).await.unwrap();
f.store.repo.insert_commit_event(&event).await.unwrap();
let pg_seq = common::sequenced_event_for_did(&f.pg, baseline, &did)
.await
.seq;
let past_cutoff = chrono::Utc::now() - chrono::Duration::hours(24);
let pg_pruned_past =
+17 -20
View File
@@ -171,7 +171,7 @@ async fn fetch_lexicon_via_atproto(nsid: &str) -> Result<LexiconDoc, ScopeExpans
return Err(ScopeExpansionError::InvalidNsid(nsid.to_string()));
}
let authority = parts[..2]
let authority = parts[..parts.len() - 1]
.iter()
.rev()
.cloned()
@@ -661,26 +661,23 @@ mod tests {
}
}
fn dns_authority(nsid: &str) -> String {
let parts: Vec<&str> = nsid.split('.').collect();
parts[..parts.len() - 1]
.iter()
.rev()
.cloned()
.collect::<Vec<_>>()
.join(".")
}
#[test]
fn test_nsid_authority_extraction_for_dns() {
let nsid = "io.atcr.authFullApp";
let parts: Vec<&str> = nsid.split('.').collect();
let authority = parts[..2]
.iter()
.rev()
.cloned()
.collect::<Vec<_>>()
.join(".");
assert_eq!(authority, "atcr.io");
let nsid2 = "app.bsky.feed.post";
let parts2: Vec<&str> = nsid2.split('.').collect();
let authority2 = parts2[..2]
.iter()
.rev()
.cloned()
.collect::<Vec<_>>()
.join(".");
assert_eq!(authority2, "bsky.app");
assert_eq!(dns_authority("io.atcr.authFullApp"), "atcr.io");
assert_eq!(dns_authority("app.bsky.feed.post"), "feed.bsky.app");
assert_eq!(
dns_authority("community.lexicon.bookmarks.authManageBookmarks"),
"bookmarks.lexicon.community"
);
}
}
+8
View File
@@ -14,13 +14,18 @@ tranquil-signal = { workspace = true }
arc-swap = { workspace = true }
axum = { workspace = true }
bytes = { workspace = true }
clap = { workspace = true }
dotenvy = { workspace = true }
ed25519-dalek = { workspace = true }
futures-util = { workspace = true }
h3 = { workspace = true }
h3-quinn = { workspace = true }
hex = { workspace = true }
http = { workspace = true }
hyper = { workspace = true }
hyper-util = { workspace = true }
quinn = { workspace = true }
rustls = { workspace = true }
rustls-pemfile = { workspace = true }
thiserror = { workspace = true }
@@ -31,6 +36,9 @@ tower = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[dev-dependencies]
rcgen = { workspace = true }
[features]
default = ["frontend", "s3", "valkey"]
frontend = ["tranquil-pds/frontend"]
+666
View File
@@ -0,0 +1,666 @@
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use axum::Router;
use axum::body::Body;
use axum::extract::ConnectInfo;
use bytes::{Buf, Bytes};
use futures_util::StreamExt;
use http::header::ALT_SVC;
use http::{HeaderValue, Request, Response, StatusCode};
use quinn::crypto::rustls::QuicServerConfig;
use quinn::{Endpoint, Incoming, ServerConfig, TransportConfig, VarInt};
use tokio::sync::Semaphore;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use tower::ServiceExt;
use tracing::debug;
use crate::tls::{ReloadableCertResolver, TlsError};
const MAX_CONCURRENT_BIDI_STREAMS: u32 = 256;
const MAX_CONCURRENT_CONNECTIONS: usize = 512;
const IDLE_TIMEOUT: Duration = Duration::from_secs(30);
const SHUTDOWN_GRACE: Duration = Duration::from_secs(10);
const ALT_SVC_MAX_AGE_SECS: u32 = 86_400;
pub fn build_quic_server_config(
resolver: Arc<ReloadableCertResolver>,
) -> Result<ServerConfig, TlsError> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let mut crypto = rustls::ServerConfig::builder_with_provider(provider)
.with_protocol_versions(&[&rustls::version::TLS13])
.map_err(|e| TlsError::Config(e.to_string()))?
.with_no_client_auth()
.with_cert_resolver(resolver);
crypto.alpn_protocols = vec![b"h3".to_vec()];
let quic_crypto =
QuicServerConfig::try_from(crypto).map_err(|e| TlsError::Config(e.to_string()))?;
let mut config = ServerConfig::with_crypto(Arc::new(quic_crypto));
let mut transport = TransportConfig::default();
transport.max_concurrent_bidi_streams(VarInt::from_u32(MAX_CONCURRENT_BIDI_STREAMS));
transport.max_idle_timeout(Some(
IDLE_TIMEOUT
.try_into()
.expect("idle timeout fits in varint"),
));
config.transport_config(Arc::new(transport));
Ok(config)
}
pub fn alt_svc_header(port: u16) -> HeaderValue {
HeaderValue::from_str(&format!("h3=\":{port}\"; ma={ALT_SVC_MAX_AGE_SECS}"))
.expect("alt-svc header value is valid ascii")
}
pub fn with_alt_svc(app: Router, port: u16) -> Router {
let value = alt_svc_header(port);
app.layer(axum::middleware::map_response(
move |mut response: Response<Body>| {
let value = value.clone();
async move {
if response.status() != StatusCode::SWITCHING_PROTOCOLS {
response.headers_mut().insert(ALT_SVC, value);
}
response
}
},
))
}
pub fn with_host_from_authority(app: Router) -> Router {
app.layer(axum::middleware::map_request(
|mut request: Request<Body>| async move {
let authority = request
.uri()
.authority()
.map(|a| HeaderValue::from_str(a.as_str()));
match (request.headers().contains_key(http::header::HOST), authority) {
(false, Some(Ok(value))) => {
request.headers_mut().insert(http::header::HOST, value);
request
}
_ => request,
}
},
))
}
pub async fn serve_http3(endpoint: Endpoint, app: Router, shutdown: CancellationToken) {
let tracker = TaskTracker::new();
let conn_limiter = Arc::new(Semaphore::new(MAX_CONCURRENT_CONNECTIONS));
loop {
tokio::select! {
_ = shutdown.cancelled() => break,
incoming = endpoint.accept() => {
let Some(incoming) = incoming else { break };
let Ok(permit) = conn_limiter.clone().try_acquire_owned() else {
debug!(
peer = %incoming.remote_address(),
max = MAX_CONCURRENT_CONNECTIONS,
"refusing h3 connection: limit reached"
);
incoming.refuse();
continue;
};
let app = app.clone();
let conn_shutdown = shutdown.clone();
let conn_tracker = tracker.clone();
tracker.spawn(async move {
let _permit = permit;
if let Err(e) = serve_connection(incoming, app, conn_shutdown, conn_tracker).await {
debug!("h3 connection ended: {e}");
}
});
}
}
}
tracker.close();
if tokio::time::timeout(SHUTDOWN_GRACE, tracker.wait())
.await
.is_err()
{
debug!("h3 connections did not drain within grace, closing");
}
endpoint.close(0u32.into(), b"shutdown");
endpoint.wait_idle().await;
}
async fn serve_connection(
incoming: Incoming,
app: Router,
shutdown: CancellationToken,
tracker: TaskTracker,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let conn = incoming.await?;
let remote = conn.remote_address();
let mut h3_conn =
h3::server::Connection::<_, Bytes>::new(h3_quinn::Connection::new(conn)).await?;
let mut draining = false;
loop {
tokio::select! {
_ = shutdown.cancelled(), if !draining => {
draining = true;
let _ = h3_conn.shutdown(0).await;
}
resolved = h3_conn.accept() => match resolved {
Ok(Some(resolver)) => {
let app = app.clone();
tracker.spawn(async move {
if let Err(e) = serve_request(resolver, app, remote).await {
debug!(peer = %remote, "h3 request failed: {e}");
}
});
}
Ok(None) => break,
Err(e) => {
debug!(peer = %remote, "h3 accept error: {e}");
break;
}
}
}
}
Ok(())
}
async fn serve_request(
resolver: h3::server::RequestResolver<h3_quinn::Connection, Bytes>,
app: Router,
remote: SocketAddr,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let (req, stream) = resolver.resolve_request().await?;
let (mut send, recv) = stream.split();
let (mut parts, ()) = req.into_parts();
parts.extensions.insert(ConnectInfo(remote));
let request = Request::from_parts(parts, request_body(recv));
let response = match app.oneshot(request).await {
Ok(response) => response,
Err(infallible) => match infallible {},
};
let (parts, body) = response.into_parts();
send.send_response(Response::from_parts(parts, ())).await?;
let mut data = body.into_data_stream();
while let Some(chunk) = data.next().await {
match chunk {
Ok(bytes) if bytes.has_remaining() => send.send_data(bytes).await?,
Ok(_) => {}
Err(e) => {
debug!(peer = %remote, "h3 response body error: {e}");
send.stop_stream(h3::error::Code::H3_INTERNAL_ERROR);
return Ok(());
}
}
}
send.finish().await?;
Ok(())
}
struct RecvGuard {
stream: h3::server::RequestStream<h3_quinn::RecvStream, Bytes>,
ended: bool,
}
impl Drop for RecvGuard {
fn drop(&mut self) {
if !self.ended {
self.stream.stop_sending(h3::error::Code::H3_NO_ERROR);
}
}
}
fn request_body(recv: h3::server::RequestStream<h3_quinn::RecvStream, Bytes>) -> Body {
let guard = RecvGuard {
stream: recv,
ended: false,
};
let stream = futures_util::stream::unfold(Some(guard), |state| async move {
let mut guard = state?;
match guard.stream.recv_data().await {
Ok(Some(mut buf)) => {
let bytes = buf.copy_to_bytes(buf.remaining());
Some((Ok::<Bytes, std::io::Error>(bytes), Some(guard)))
}
Ok(None) => {
guard.ended = true;
None
}
Err(e) => {
guard.ended = true;
Some((Err(std::io::Error::other(e.to_string())), None))
}
}
});
Body::from_stream(stream)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::routing::get;
use rustls::pki_types::{
CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName, UnixTime,
};
fn self_signed_resolver() -> Arc<ReloadableCertResolver> {
let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
let cert_der = cert.cert.der().clone();
let key_der =
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()));
let signing_key = rustls::crypto::ring::sign::any_supported_type(&key_der).unwrap();
let certified = rustls::sign::CertifiedKey::new(vec![cert_der], signing_key);
Arc::new(ReloadableCertResolver::new(certified))
}
#[derive(Debug)]
struct SkipServerVerification(Arc<rustls::crypto::CryptoProvider>);
impl rustls::client::danger::ServerCertVerifier for SkipServerVerification {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&self.0.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&self.0.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
self.0.signature_verification_algorithms.supported_schemes()
}
}
fn client_endpoint() -> Endpoint {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let mut crypto = rustls::ClientConfig::builder_with_provider(provider.clone())
.with_protocol_versions(&[&rustls::version::TLS13])
.unwrap()
.dangerous()
.with_custom_certificate_verifier(Arc::new(SkipServerVerification(provider)))
.with_no_client_auth();
crypto.alpn_protocols = vec![b"h3".to_vec()];
let quic = quinn::crypto::rustls::QuicClientConfig::try_from(crypto).unwrap();
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap()).unwrap();
endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new(quic)));
endpoint
}
#[tokio::test]
async fn h3_get_roundtrips_through_router() {
let app = Router::new().route("/", get(|| async { "ok" }));
let server_config = build_quic_server_config(self_signed_resolver()).unwrap();
let server = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
let addr = server.local_addr().unwrap();
let shutdown = CancellationToken::new();
tokio::spawn(serve_http3(server, app, shutdown.clone()));
let client = client_endpoint();
let conn = client.connect(addr, "localhost").unwrap().await.unwrap();
let (mut driver, mut send_request) = h3::client::new(h3_quinn::Connection::new(conn))
.await
.unwrap();
let drive =
tokio::spawn(async move { std::future::poll_fn(|cx| driver.poll_close(cx)).await });
let req = Request::get("https://localhost/").body(()).unwrap();
let mut stream = send_request.send_request(req).await.unwrap();
stream.finish().await.unwrap();
let response = stream.recv_response().await.unwrap();
assert_eq!(response.status(), 200);
let mut body = Vec::new();
while let Some(mut chunk) = stream.recv_data().await.unwrap() {
let bytes = chunk.copy_to_bytes(chunk.remaining());
body.extend_from_slice(&bytes);
}
assert_eq!(body.as_slice(), b"ok");
shutdown.cancel();
drive.abort();
}
#[test]
fn alt_svc_header_advertises_h3() {
assert_eq!(
alt_svc_header(443).to_str().unwrap(),
"h3=\":443\"; ma=86400"
);
}
#[tokio::test]
async fn alt_svc_added_to_responses_except_switching_protocols() {
let app = with_alt_svc(
Router::new().route("/ok", get(|| async { "ok" })).route(
"/upgrade",
get(|| async {
Response::builder()
.status(StatusCode::SWITCHING_PROTOCOLS)
.body(Body::empty())
.unwrap()
}),
),
443,
);
let normal = app
.clone()
.oneshot(Request::get("/ok").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
normal.headers().get(ALT_SVC).and_then(|v| v.to_str().ok()),
Some("h3=\":443\"; ma=86400")
);
let upgrade = app
.oneshot(Request::get("/upgrade").body(Body::empty()).unwrap())
.await
.unwrap();
assert!(
upgrade.headers().get(ALT_SVC).is_none(),
"101 responses must not carry Alt-Svc"
);
}
fn make_cert(dns: &str) -> (rustls::sign::CertifiedKey, CertificateDer<'static>) {
let cert = rcgen::generate_simple_self_signed(vec![dns.to_string()]).unwrap();
let cert_der = cert.cert.der().clone();
let key_der =
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()));
let signing_key = rustls::crypto::ring::sign::any_supported_type(&key_der).unwrap();
let certified = rustls::sign::CertifiedKey::new(vec![cert_der.clone()], signing_key);
(certified, cert_der)
}
#[derive(Debug)]
struct RecordingVerifier {
provider: Arc<rustls::crypto::CryptoProvider>,
seen: Arc<std::sync::Mutex<Vec<u8>>>,
}
impl rustls::client::danger::ServerCertVerifier for RecordingVerifier {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
*self.seen.lock().unwrap() = end_entity.as_ref().to_vec();
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
self.provider
.signature_verification_algorithms
.supported_schemes()
}
}
async fn observe_server_cert(addr: SocketAddr) -> Vec<u8> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
let verifier = Arc::new(RecordingVerifier {
provider: provider.clone(),
seen: seen.clone(),
});
let mut crypto = rustls::ClientConfig::builder_with_provider(provider)
.with_protocol_versions(&[&rustls::version::TLS13])
.unwrap()
.dangerous()
.with_custom_certificate_verifier(verifier)
.with_no_client_auth();
crypto.alpn_protocols = vec![b"h3".to_vec()];
let quic = quinn::crypto::rustls::QuicClientConfig::try_from(crypto).unwrap();
let mut endpoint = Endpoint::client("127.0.0.1:0".parse().unwrap()).unwrap();
endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new(quic)));
let conn = endpoint.connect(addr, "localhost").unwrap().await.unwrap();
conn.close(0u32.into(), b"done");
endpoint.wait_idle().await;
seen.lock().unwrap().clone()
}
#[tokio::test]
async fn quic_handshake_observes_reloaded_certificate() {
let (cert_a, der_a) = make_cert("localhost");
let (cert_b, der_b) = make_cert("localhost");
assert_ne!(der_a, der_b, "test must use two distinct certs");
let resolver = Arc::new(ReloadableCertResolver::new(cert_a));
let server_config = build_quic_server_config(resolver.clone()).unwrap();
let server = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
let addr = server.local_addr().unwrap();
let shutdown = CancellationToken::new();
let app = Router::new().route("/", get(|| async { "ok" }));
tokio::spawn(serve_http3(server, app, shutdown.clone()));
let before = observe_server_cert(addr).await;
assert_eq!(
before.as_slice(),
der_a.as_ref(),
"first handshake must present the original cert"
);
resolver.store(cert_b);
let after = observe_server_cert(addr).await;
assert_eq!(
after.as_slice(),
der_b.as_ref(),
"handshake after reload must present the new cert"
);
assert_ne!(before, after, "reload must change the presented cert");
shutdown.cancel();
}
#[tokio::test]
async fn h3_requests_carry_remote_addr_connect_info() {
let app = Router::new().route(
"/",
get(|ConnectInfo(addr): ConnectInfo<SocketAddr>| async move { addr.to_string() }),
);
let server_config = build_quic_server_config(self_signed_resolver()).unwrap();
let server = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
let addr = server.local_addr().unwrap();
let shutdown = CancellationToken::new();
tokio::spawn(serve_http3(server, app, shutdown.clone()));
let client = client_endpoint();
let client_port = client.local_addr().unwrap().port();
let conn = client.connect(addr, "localhost").unwrap().await.unwrap();
let (mut driver, mut send_request) = h3::client::new(h3_quinn::Connection::new(conn))
.await
.unwrap();
let drive =
tokio::spawn(async move { std::future::poll_fn(|cx| driver.poll_close(cx)).await });
let req = Request::get("https://localhost/").body(()).unwrap();
let mut stream = send_request.send_request(req).await.unwrap();
stream.finish().await.unwrap();
assert_eq!(stream.recv_response().await.unwrap().status(), 200);
let mut body = Vec::new();
while let Some(mut chunk) = stream.recv_data().await.unwrap() {
let bytes = chunk.copy_to_bytes(chunk.remaining());
body.extend_from_slice(&bytes);
}
let reported: SocketAddr = String::from_utf8(body).unwrap().parse().unwrap();
assert!(reported.ip().is_loopback());
assert_eq!(
reported.port(),
client_port,
"handlers must see the QUIC remote address via ConnectInfo"
);
shutdown.cancel();
drive.abort();
}
#[tokio::test]
async fn host_header_filled_from_authority() {
let app = with_host_from_authority(Router::new().route(
"/",
get(|headers: http::HeaderMap| async move {
headers
.get(http::header::HOST)
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
.unwrap_or_default()
}),
));
let server_config = build_quic_server_config(self_signed_resolver()).unwrap();
let server = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
let addr = server.local_addr().unwrap();
let shutdown = CancellationToken::new();
tokio::spawn(serve_http3(server, app, shutdown.clone()));
let client = client_endpoint();
let conn = client.connect(addr, "localhost").unwrap().await.unwrap();
let (mut driver, mut send_request) = h3::client::new(h3_quinn::Connection::new(conn))
.await
.unwrap();
let drive =
tokio::spawn(async move { std::future::poll_fn(|cx| driver.poll_close(cx)).await });
let req = Request::get("https://localhost/").body(()).unwrap();
let mut stream = send_request.send_request(req).await.unwrap();
stream.finish().await.unwrap();
assert_eq!(stream.recv_response().await.unwrap().status(), 200);
let mut body = Vec::new();
while let Some(mut chunk) = stream.recv_data().await.unwrap() {
let bytes = chunk.copy_to_bytes(chunk.remaining());
body.extend_from_slice(&bytes);
}
assert_eq!(
String::from_utf8(body).unwrap(),
"localhost",
"handlers must see the authority as the Host header"
);
shutdown.cancel();
drive.abort();
}
#[tokio::test]
async fn h3_body_error_resets_stream_instead_of_truncating() {
let app = Router::new().route(
"/",
get(|| async {
Body::from_stream(futures_util::stream::iter(vec![
Ok::<Bytes, std::io::Error>(Bytes::from_static(b"partial")),
Err(std::io::Error::other("body source failed")),
]))
}),
);
let server_config = build_quic_server_config(self_signed_resolver()).unwrap();
let server = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
let addr = server.local_addr().unwrap();
let shutdown = CancellationToken::new();
tokio::spawn(serve_http3(server, app, shutdown.clone()));
let client = client_endpoint();
let conn = client.connect(addr, "localhost").unwrap().await.unwrap();
let (mut driver, mut send_request) = h3::client::new(h3_quinn::Connection::new(conn))
.await
.unwrap();
let drive =
tokio::spawn(async move { std::future::poll_fn(|cx| driver.poll_close(cx)).await });
let req = Request::get("https://localhost/").body(()).unwrap();
let mut stream = send_request.send_request(req).await.unwrap();
stream.finish().await.unwrap();
let outcome = async {
stream.recv_response().await?;
let mut body = Vec::new();
loop {
match stream.recv_data().await {
Ok(Some(mut chunk)) => {
let bytes = chunk.copy_to_bytes(chunk.remaining());
body.extend_from_slice(&bytes);
}
Ok(None) => return Ok(body),
Err(e) => return Err(e),
}
}
}
.await;
assert!(
outcome.is_err(),
"a mid-body error must reset the stream, not end the body cleanly after {} bytes",
outcome.map(|b| b.len()).unwrap_or(0)
);
shutdown.cancel();
drive.abort();
}
}
+32 -4
View File
@@ -14,6 +14,7 @@ use tranquil_pds::scheduled::{
};
use tranquil_pds::state::AppState;
mod http3;
mod tls;
#[derive(Parser)]
@@ -259,7 +260,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
shutdown.clone(),
));
let app = tranquil_pds::app_with_routes(
let app = http3::with_host_from_authority(tranquil_pds::app_with_routes(
state,
tranquil_pds::ExternalRoutes {
xrpc: tranquil_api::api_routes().merge(tranquil_sync::sync_routes()),
@@ -270,7 +271,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
.merge(tranquil_api::webhook_routes())
.merge(tranquil_oauth_server::frontend_client_metadata_route()),
},
);
));
let cfg = tranquil_config::get();
let host = &cfg.server.host;
@@ -286,6 +287,8 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
.await
.map_err(|e| format!("Failed to bind to {}: {}", addr, e))?;
let mut http3_handle: Option<tokio::task::JoinHandle<()>> = None;
let server_handle = match cfg.server.tls.material() {
Some((cert_path, key_path)) => {
let initial = tls::load_certified_key(cert_path, key_path)
@@ -296,14 +299,35 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
.map_err(|e| format!("Failed to build TLS configuration: {e}"))?,
);
tls::spawn_reload_handler(
resolver,
resolver.clone(),
cert_path.to_string(),
key_path.to_string(),
shutdown.clone(),
);
let tcp_app = if cfg.server.tls.http3 {
let quic_config = http3::build_quic_server_config(resolver)
.map_err(|e| format!("Failed to build HTTP/3 configuration: {e}"))?;
let endpoint = quinn::Endpoint::server(quic_config, addr)
.map_err(|e| format!("Failed to bind HTTP/3 endpoint on {addr}: {e}"))?;
let h3_port = endpoint
.local_addr()
.map(|a| a.port())
.map_err(|e| format!("Failed to read HTTP/3 local address: {e}"))?;
info!("HTTP/3 enabled on udp/{h3_port}");
http3_handle = Some(tokio::spawn(http3::serve_http3(
endpoint,
app.clone(),
shutdown.clone(),
)));
http3::with_alt_svc(app, h3_port)
} else {
app
};
info!("TLS termination enabled (h2, http/1.1), reload with SIGHUP");
let shutdown = shutdown.clone();
tokio::spawn(tls::serve_tls(listener, app, server_config, shutdown))
tokio::spawn(tls::serve_tls(listener, tcp_app, server_config, shutdown))
}
None => {
let make_service = app.into_make_service_with_connect_info::<SocketAddr>();
@@ -332,6 +356,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
.await
.map_err(|e| format!("Server task panicked: {}", e))?;
if let Some(handle) = http3_handle {
handle.await.ok();
}
comms_handle.await.ok();
if let Some(handle) = crawlers_handle {
+3
View File
@@ -211,6 +211,9 @@ pub async fn serve_tls(
async move {
match accepted {
Ok((tcp, peer)) => {
if let Err(e) = tcp.set_nodelay(true) {
debug!("failed to set nodelay for {peer}: {e}");
}
let permit = tokio::select! {
biased;
_ = conn_shutdown.cancelled() => return,
+3 -1
View File
@@ -38,11 +38,13 @@ tempfile = { version = "3", optional = true }
clap = { workspace = true, optional = true }
toml = { version = "0.8", optional = true }
tracing-subscriber = { workspace = true, features = ["env-filter"], optional = true }
tikv-jemallocator = { version = "0.6", optional = true }
libc = "0.2"
[features]
test-harness = ["dep:tempfile"]
gauntlet-cli = ["test-harness", "dep:clap", "dep:toml", "dep:tracing-subscriber"]
jemalloc = ["dep:tikv-jemallocator"]
gauntlet-cli = ["test-harness", "dep:clap", "dep:toml", "dep:tracing-subscriber", "jemalloc"]
gauntlet-jemalloc-prof = []
[[bin]]
+10 -3
View File
@@ -13,6 +13,11 @@ use sha2::{Digest, Sha256};
use tranquil_store::blockstore::{
BlockStoreConfig, DEFAULT_MAX_FILE_SIZE, GroupCommitConfig, TranquilBlockStore,
};
use tranquil_store::{RealIO, SystemClock};
#[cfg(feature = "jemalloc")]
#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
const DAG_CBOR_CODEC: u64 = 0x71;
const SHA2_256_CODE: u64 = 0x12;
@@ -60,11 +65,11 @@ fn compute_stats(durations: &mut [Duration]) -> Option<LatencyStats> {
})
}
fn open_store(dir: &Path) -> TranquilBlockStore {
fn open_store(dir: &Path) -> TranquilBlockStore<RealIO, SystemClock> {
open_store_sharded(dir, 1)
}
fn open_store_sharded(dir: &Path, shard_count: u8) -> TranquilBlockStore {
fn open_store_sharded(dir: &Path, shard_count: u8) -> TranquilBlockStore<RealIO, SystemClock> {
TranquilBlockStore::open(BlockStoreConfig {
data_dir: dir.join("data"),
index_dir: dir.join("index"),
@@ -183,7 +188,9 @@ async fn bench_read_throughput(block_count: usize, concurrency: usize) {
cids
};
let run_reads = |label: &'static str, store: TranquilBlockStore, cids: Vec<Cid>| async move {
let run_reads = |label: &'static str,
store: TranquilBlockStore<RealIO, SystemClock>,
cids: Vec<Cid>| async move {
let start = Instant::now();
let handles: Vec<_> = (0..concurrency)
@@ -0,0 +1,11 @@
scenario = "moderate-faults"
seeds = 128
dump_regressions = "/root/gauntlet-regressions"
[base_overrides]
op_count = 20000
[axes]
fault_density_scale = [0.0, 1.0]
advance_time = [20, 60]
advance_max_secs = [3600, 86400, 604800]
@@ -0,0 +1,7 @@
scenario = "firehose-fanout"
seeds = 128
dump_regressions = "/root/gauntlet-regressions"
[axes]
advance_time = [20, 60]
advance_max_secs = [3600, 86400, 604800]
@@ -0,0 +1,7 @@
scenario = "eventlog-time-travel-chaos"
seeds = 256
dump_regressions = "/root/gauntlet-regressions"
[overrides]
op_count = 4000
fault_density_scale = 0.1
@@ -0,0 +1,7 @@
scenario = "eventlog-time-travel-chaos"
seeds = 256
dump_regressions = "/root/gauntlet-regressions"
[overrides]
op_count = 4000
fault_density_scale = 1.0
@@ -0,0 +1,6 @@
scenario = "eventlog-time-travel-chaos"
seeds = 64
[axes]
fault_density_scale = [0.0, 1.0]
advance_time = [0, 20]
+4 -3
View File
@@ -8,6 +8,7 @@ use crate::blockstore::{
BlockOffset, BlockstoreSnapshot, CommitEpoch, CommitError, DataFileId, QuiesceGuard,
RebuildError, TranquilBlockStore,
};
use crate::clock::SystemClock;
use crate::eventlog::{
EventLog, EventLogConfig, EventLogFreezeGuard, EventLogSnapshotState, EventSequence,
EventWithMutations, SegmentId, SegmentOffset,
@@ -157,14 +158,14 @@ enum BackupLineage<'a> {
}
pub struct BackupCoordinator<'a, S: StorageIO> {
blockstore: &'a TranquilBlockStore,
blockstore: &'a TranquilBlockStore<RealIO, SystemClock>,
eventlog: &'a EventLog<S>,
metastore: &'a Metastore,
}
impl<'a, S: StorageIO + Send + Sync + 'static> BackupCoordinator<'a, S> {
pub fn new(
blockstore: &'a TranquilBlockStore,
blockstore: &'a TranquilBlockStore<RealIO, SystemClock>,
eventlog: &'a EventLog<S>,
metastore: &'a Metastore,
) -> Self {
@@ -312,7 +313,7 @@ impl<'a, S: StorageIO + Send + Sync + 'static> BackupCoordinator<'a, S> {
BackupManifest {
version: BACKUP_FORMAT_VERSION,
created_at_ms: crate::wall_clock_ms().raw(),
created_at_ms: crate::blockstore::WallClockMs::now().raw(),
blockstore: {
let max_cursor = bs
.shard_cursors
@@ -14,6 +14,10 @@ use tranquil_store::gauntlet::{
shrink::{DEFAULT_MAX_SHRINK_ITERATIONS, shrink_failure},
};
#[cfg(feature = "jemalloc")]
#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
const MAX_HOURS: f64 = 1.0e6;
const DEFAULT_SWEEP_RUN_CAP: u64 = 10_000;
@@ -223,6 +227,10 @@ struct SweepAxes {
commit_batch_size: Vec<usize>,
#[serde(default)]
max_file_size: Vec<u64>,
#[serde(default)]
advance_time: Vec<u32>,
#[serde(default)]
advance_max_secs: Vec<u32>,
}
#[derive(Debug, Clone, Copy, Default)]
@@ -235,6 +243,8 @@ struct SweepAxisValues {
restart_every_n_ops: Option<usize>,
commit_batch_size: Option<usize>,
max_file_size: Option<u64>,
advance_time: Option<u32>,
advance_max_secs: Option<u32>,
}
impl SweepAxisValues {
@@ -263,51 +273,61 @@ impl SweepAxisValues {
if let Some(v) = self.max_file_size {
o.store.max_file_size = Some(v);
}
if let Some(v) = self.advance_time {
o.advance_time = Some(v);
}
if let Some(v) = self.advance_max_secs {
o.advance_max_secs = Some(v);
}
}
}
impl SweepAxes {
fn axis_values(&self) -> Vec<SweepAxisValues> {
expand(&self.writer_concurrency)
.into_iter()
.flat_map(|wc| {
expand(&self.key_space).into_iter().flat_map(move |ks| {
expand(&self.value_bytes).into_iter().flat_map(move |vb| {
expand(&self.fault_density_scale)
.into_iter()
.flat_map(move |fds| {
expand(&self.fault_density_uniform).into_iter().flat_map(
move |fdu| {
expand(&self.restart_every_n_ops).into_iter().flat_map(
move |rc| {
expand(&self.commit_batch_size)
.into_iter()
.flat_map(move |cb| {
expand(&self.max_file_size).into_iter().map(
move |mfs| SweepAxisValues {
writer_concurrency: wc,
key_space: ks,
value_bytes: vb,
fault_density_scale: fds,
fault_density_uniform: fdu,
restart_every_n_ops: rc,
commit_batch_size: cb,
max_file_size: mfs,
},
)
})
},
)
},
)
})
})
})
})
.collect()
let base = vec![SweepAxisValues::default()];
let base = cross(base, &self.writer_concurrency, |a, v| {
a.writer_concurrency = Some(v)
});
let base = cross(base, &self.key_space, |a, v| a.key_space = Some(v));
let base = cross(base, &self.value_bytes, |a, v| a.value_bytes = Some(v));
let base = cross(base, &self.fault_density_scale, |a, v| {
a.fault_density_scale = Some(v)
});
let base = cross(base, &self.fault_density_uniform, |a, v| {
a.fault_density_uniform = Some(v)
});
let base = cross(base, &self.restart_every_n_ops, |a, v| {
a.restart_every_n_ops = Some(v)
});
let base = cross(base, &self.commit_batch_size, |a, v| {
a.commit_batch_size = Some(v)
});
let base = cross(base, &self.max_file_size, |a, v| a.max_file_size = Some(v));
let base = cross(base, &self.advance_time, |a, v| a.advance_time = Some(v));
cross(base, &self.advance_max_secs, |a, v| {
a.advance_max_secs = Some(v)
})
}
}
fn cross<T: Copy>(
acc: Vec<SweepAxisValues>,
values: &[T],
set: impl Fn(&mut SweepAxisValues, T) + Copy,
) -> Vec<SweepAxisValues> {
acc.into_iter()
.flat_map(|base| {
expand(values).into_iter().map(move |opt| {
let mut next = base;
if let Some(v) = opt {
set(&mut next, v);
}
next
})
})
.collect()
}
fn expand<T: Copy>(values: &[T]) -> Vec<Option<T>> {
if values.is_empty() {
vec![None]
@@ -613,7 +633,22 @@ fn install_interrupt(rt: &Runtime) -> Arc<AtomicBool> {
flag
}
fn raise_fd_limit() {
let mut lim = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
let read = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut lim) } == 0;
if read && lim.rlim_cur < lim.rlim_max {
lim.rlim_cur = lim.rlim_max;
unsafe {
let _ = libc::setrlimit(libc::RLIMIT_NOFILE, &lim);
}
}
}
fn main() -> ExitCode {
raise_fd_limit();
let _ = tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
@@ -9,6 +9,7 @@ use super::hint::{HintFileWriter, hint_file_path};
use super::manager::DataFileManager;
use super::types::{
BlockLocation, CidBytes, CommitEpoch, CompactionResult, CompactionStats, DataFileId,
WallClockMs,
};
#[derive(Debug)]
@@ -65,6 +66,7 @@ pub(super) fn compact_on_writer_thread<S: StorageIO>(
active_files: &ActiveFileSet,
hint_positions: &super::group_commit::ShardHintPositions,
epoch: &super::types::EpochCounter,
now: WallClockMs,
) -> Result<CompactionResult, CompactionError> {
if active_files.contains(source_file_id) {
return Err(CompactionError::ActiveFileCannotBeCompacted);
@@ -89,6 +91,7 @@ pub(super) fn compact_on_writer_thread<S: StorageIO>(
new_file_id,
current_epoch,
grace_period_ms,
now,
);
match result {
@@ -183,6 +186,112 @@ fn purge_phantom_file<S: StorageIO>(
})
}
#[allow(clippy::too_many_arguments)]
pub(super) fn repair_blocks_on_writer_thread<S: StorageIO>(
manager: &DataFileManager<S>,
index: &BlockIndex,
blocks: &[(CidBytes, Vec<u8>)],
current_epoch: CommitEpoch,
file_ids: &FileIdAllocator,
hint_positions: &super::group_commit::ShardHintPositions,
epoch: &super::types::EpochCounter,
) -> Result<u64, CompactionError> {
if blocks.is_empty() {
return Ok(0);
}
let new_file_id = file_ids.allocate();
let new_handle = manager.open_for_append(new_file_id)?;
let mut writer = DataFileWriter::new(manager.io(), new_handle.fd(), new_file_id)?;
let hint_path = hint_file_path(manager.data_dir(), new_file_id);
let hint_fd = manager.io().open(&hint_path, OpenOptions::read_write())?;
let mut hint_writer = HintFileWriter::new(manager.io(), hint_fd);
let mut relocations: Vec<(CidBytes, BlockLocation)> = Vec::with_capacity(blocks.len());
let write_result = blocks.iter().try_for_each(|(cid, data)| {
let refcount = index.get(cid).map(|e| e.refcount.raw()).unwrap_or(1).max(1);
let loc = writer.append_block(cid, data)?;
hint_writer.append_relocate(cid, &loc, refcount)?;
relocations.push((*cid, loc));
Ok::<_, CompactionError>(())
});
let record_count = u32::try_from(relocations.len()).unwrap_or(u32::MAX);
let writer_position = writer.position();
let finalize = write_result
.and_then(|()| writer.sync().map_err(CompactionError::from))
.and_then(|()| {
hint_writer
.append_commit_marker(
current_epoch.raw(),
record_count,
new_file_id,
writer_position,
)
.map_err(CompactionError::from)
})
.and_then(|()| hint_writer.sync().map_err(CompactionError::from))
.and_then(|()| {
manager
.io()
.sync_dir(manager.data_dir())
.map_err(CompactionError::from)
})
.and_then(|()| manager.io().barrier().map_err(CompactionError::from));
let final_hint_offset = hint_writer.position();
let _ = manager.io().close(hint_fd);
if let Err(e) =
finalize.and_then(|()| verify_repaired_blocks(manager, new_file_id, &relocations))
{
manager.delete_data_file(new_file_id).ok();
manager
.io()
.delete(&hint_file_path(manager.data_dir(), new_file_id))
.ok();
return Err(e);
}
hint_positions.record_extra(new_file_id, final_hint_offset);
index.apply_compaction(&relocations, &[]);
index
.write_checkpoint(epoch.current(), hint_positions)
.map_err(CompactionError::Io)?;
tracing::info!(
dest = %new_file_id,
repaired = relocations.len(),
"structural repair complete"
);
Ok(relocations.len() as u64)
}
fn verify_repaired_blocks<S: StorageIO>(
manager: &DataFileManager<S>,
file_id: DataFileId,
relocations: &[(CidBytes, BlockLocation)],
) -> Result<(), CompactionError> {
let handle = manager.open_for_read(file_id)?;
let file_size = manager.io().file_size(handle.fd())?;
relocations.iter().try_for_each(|(cid, loc)| {
match super::data_file::decode_block_record(
manager.io(),
handle.fd(),
loc.offset,
file_size,
) {
Ok(Some(ReadBlockRecord::Valid { cid_bytes, .. })) if cid_bytes == *cid => Ok(()),
_ => Err(CompactionError::Io(io::Error::other(
"repaired block failed read-back verification",
))),
}
})
}
#[allow(clippy::too_many_arguments)]
fn stream_compact<S: StorageIO>(
manager: &DataFileManager<S>,
index: &BlockIndex,
@@ -191,9 +300,9 @@ fn stream_compact<S: StorageIO>(
new_file_id: DataFileId,
current_epoch: CommitEpoch,
grace_period_ms: u64,
now: WallClockMs,
) -> Result<(u64, u64, u64, super::types::HintOffset), CompactionError> {
let mut reader = DataFileReader::open(manager.io(), source_fd)?;
let now = crate::wall_clock_ms();
let new_handle = manager.open_for_append(new_file_id)?;
let mut writer = DataFileWriter::new(manager.io(), new_handle.fd(), new_file_id)?;

Some files were not shown because too many files have changed in this diff Show More