Compare commits

..
Author SHA1 Message Date
nelind e85394c314 build: bump version to 0.6.3 2026-05-27 19:28:10 +02:00
isabelandTangled 411c6be108 feat(nix): validate config at build time 2026-05-27 20:14:06 +03:00
isabelandTangled 28aa7ab7fc feat!(tranquil-config): error on unknown keys
an attempt to make it so that fixes like
fc6063dba8 never have to occur again
2026-05-27 20:13:58 +03:00
nelindandTangled 348b69d76b feat(nix): ensure that tranquil-store path is always writable by the service user just like file blob storage 2026-05-27 20:13:07 +03:00
nelindandTangled 4b23ca2c36 fix(docs): some wording on nix config and CI stuffs 2026-05-27 20:13:07 +03:00
LewisandTangled a7052e878c feat: tranquil's own TLS handling
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-27 13:54:33 +03:00
nelind fc6063dba8 fix(nix): override the right frontend package setting in the flake nixos module 2026-05-27 01:04:31 +02:00
LewisandTangled 44cb016762 fix(docs): install docs better
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-26 09:49:59 +03:00
LewisandTangled e6eee18ace fix(docs): migration oauth update
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-25 19:02:13 +03:00
Lewis 96c8375706 fix(test): oauth token eviction should be agnostic
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-25 16:17:10 +03:00
Lewis 86c5995568 fix(config): signal gate is useless since needs admin work to activate anyway
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-24 22:50:14 +03:00
LewisandTangled 036c317fd6 fix(migrate): oauth refreshing, embedded db first invite code
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-24 19:28:57 +03:00
LewisandTangled 4d2c7d4723 feat(auth): verification-gate override, inbound-migration bypass, store deleter improvement
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-23 23:02:43 +03:00
isabelandTangled f24a9f8bc0 build(nix): frontend pin pnpm
some future protections against this acursed toolchain breaking and
people who do .follows on their nixpkgs inputs
2026-05-22 23:24:45 +03:00
Lewis f6ef6ecbd9 feat(docs): first version of 'when PRing' section of contrib
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-21 22:46:59 +03:00
karitham.devandTangled a2567bdb1a fix(locales): french handle should be "identifier" not "username"
My bad translation on that earlier, made it consistent with the rest of the file and usage!
2026-05-21 10:39:29 +03:00
Lewis 56120d252d fix(locale): a little more drift
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-21 09:14:06 +03:00
TrezyandTangled 210b0f463c feat: add About page
Signed-off-by: Trezy <tre@trezy.com>
2026-05-21 09:06:42 +03:00
LewisandTangled c80f504dc0 fix(locale): a couple of drift keys missing in other langs
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-21 09:06:10 +03:00
karitham.devandTangled a3bd7c59ad feat(locales): add french locale, fix missing error keys, remove dead keys
Adds french locale registration and translation file. Fixes 4 missing error keys that were referenced in code but absent from all locale files. Removes 2 unreferenced keys from `register.validation` that duplicated `registerPasskey.errors`.

I *think* that's all that's needed for french locale? Couldn't find anything else required to make it work!
2026-05-20 22:20:20 +03:00
nelind 9ebde27540 fix(docs): markdown is tricky ... 2026-05-19 21:30:35 +02:00
nelindandTangled 28a7834304 docs: nel interjection: be right, be good, be fun 2026-05-19 22:18:38 +03:00
Lewis 191da5b311 chore: slight doc tweak
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-19 21:14:28 +03:00
Lewis a5b4ba7d65 feat(docs): init better docs
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-19 20:45:50 +03:00
TrezyandTangled 90dabd8840 docs: start a contributing file to track how to work with the repo
Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 19eaccea74 chore: add dev startup to justfile
Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled f620a6bc43 chore: add mailpit for local dev
Captures outbound emails, greatly simplifying local dev.

Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 04f370aaa1 chore: add frontend service to docker compose
Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 3fd8f7ebbf chore: add app service with cargo-watch
Spins up a container with Tranquil running, but automatically uses incremental rebuilds for local dev.

Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 0afcb2ee28 chore: add plc directory for local dev
Prevents us from polluting the real PLC directory.

Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 8bd556f65b chore: add db health check to docker compose
Safer wafe to depend on database startup.

Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled cc92594506 chore: add traefik for local dev certs and routing
Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 76f22b801b chore: allow native certs during local dev
Allows Traefik certs to be trusted by Tranquil.

Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:58 +03:00
TrezyandTangled 021b7dbec4 chore: add profiles to docker compose
Allows us to spin up groups from the compose file

Signed-off-by: Trezy <tre@trezy.com>
2026-05-19 19:46:57 +03:00
nelind eb034cb8b3 fix(ci): dont pass cachix cache name as a secret so it doesnt get redacted from CI logs 2026-05-17 17:07:34 +02:00
Lewis bdaf510898 build: bump workspace to 0.6.2
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-17 12:32:05 +03:00
Lewis deb2502112 feat(pds): phantom-file self-heal goes in scheduled compaction + reachability walk
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-17 12:32:05 +03:00
Lewis 1815ddba9f feat(gauntlet): index-backed/hint-backed/readable invariants, ExternalCorruption scenario
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-17 12:32:05 +03:00
Lewis a7517ed5c9 feat(store): consistency check & repair for orphan hints etc
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-17 11:50:32 +03:00
Lewis d07d702dd4 feat(store): try to self-heal phantom index entries on compaction
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-17 11:50:19 +03:00
isabelandTangled 1901b0a630 fix(nix): build against pnpm 11
side note: you may also notice that I have removed the `github:` url for
nixpkgs this is because it save a massive 15mb, is faster and more
reliable than github
See also:
-
<https://nix.dev/manual/nix/stable/protocols/tarball-fetcher#lockable-http-tarball-protocol>
-
<http://web.archive.org/web/20250806225139/https://nix.dev/manual/nix/2.28/protocols/tarball-fetcher#lockable-http-tarball-protocol>
2026-05-14 21:37:28 +03:00
serenityandTangled 58f8d327c1 feat(docs): clarify nix documentation 2026-05-14 16:52:10 +03:00
LewisandTangled a13343e1de fix(oauth): gc tokens in pg in the right order, more exposure of dpop err
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-14 16:51:53 +03:00
Bas van den WollenbergandTangled 60e10af4aa fix(Dockerfile): pnpm v11 build errors
- pnpm doesn't implicitly ignore post-install scripts anymore which means trying to build the docker image now fails. i've explicitly allowed them but idk could also ignore them maybe not sure what they actually do lol

- added `confirmModulesPurge: false` but not sure what it actually does as it's not documented? but either way it's also required for the `pnpm build` step to succeed non-interactively it seems idk weird stuff

- removed msmtp/sendmail while i was at it just like https://tangled.org/tranquil.farm/tranquil-pds/commit/f176f55862bc4c41472f8697cc2ca069c24d5e37
2026-05-14 09:56:07 +03:00
isabelandTangled f176f55862 chore(nix): remove sendmail 2026-05-02 23:32:57 +03:00
LewisandTangled fac9520a16 feat(tranquil-server): email config, tests, fmt
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-02 22:28:59 +03:00
LewisandTangled eee6fb9ff4 feat(comms): EmailSender, permanent/transient routing
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-02 22:28:59 +03:00
LewisandTangled 2462d0ab3b feat(tranquil-comms): smtp and dkim signing
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-02 22:28:59 +03:00
LewisandTangled 85f87f7b28 feat(tranquil-comms): message construction and mx resolution
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-02 22:28:59 +03:00
LewisandTangled b1d86caa78 feat(tranquil-comms): prework for email
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-02 22:28:59 +03:00
Lewis 9b2cfb3a7e fix(tranquil-store): durable-tail recovery + sync semantics
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-30 11:11:28 +03:00
Lewis efd499bb26 fix(tranquil-store): barrier durability + torn-header recovery
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-29 15:35:42 +03:00
nelindandTangled d4dfe838eb fix(ci): use kaniko to build 2026-04-28 23:06:36 +03:00
LewisandTangled af3821514f test(tranquil-pds): same-rkey batch coverage and inductive inverse for in-batch dups
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-28 22:05:03 +03:00
LewisandTangled 8f7aad3756 fix(tranquil-pds): same-rkey batch semantics and firehose lag recovery
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-28 22:05:03 +03:00
LewisandTangled 75b9e3165f refactor(deploy): container-first cleanup, drop debian malware-style install
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-27 00:13:27 +03:00
LewisandTangled ccc9916109 test(tranquil-pds): websocket firehose end-to-end mst verification
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-26 20:11:27 +03:00
LewisandTangled bc8fd66a45 test(tranquil-pds): mst fuzz + repo integrity properties
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-26 20:11:27 +03:00
LewisandTangled 180de29984 fix(tranquil-pds): firehose car carries inductive proof
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-26 20:11:27 +03:00
max.andTangled 0455dc20bd fix(validation): discord usernames may contain periods at start or end 2026-04-25 09:59:12 +03:00
Lewis 2770b9b14a feat(tranquil-store): cargo-fuzz targets with asan+ubsan
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-24 10:50:08 +03:00
Lewis d436597184 feat(tranquil-store): flaky-device scenario, jemalloc heap-prof
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-24 10:50:08 +03:00
Lewis 4cfca6d956 feat(tranquil-store): soak harness driving leak gate, signal tweaks
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-23 08:39:16 +03:00
Lewis 98b94fb170 feat(tranquil-store): leak gate and metrics sampling for gauntlet
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-22 21:00:17 +03:00
Lewis 4fe01cff72 feat(tranquil-store): sweep subcommand with axis override fan-out
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-22 17:23:15 +03:00
Lewis 00c9eb732f fix(tranquil-store): arc-counted cache handles, reader-eviction race
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-21 22:04:24 +03:00
Lewis 6d2d3b4be4 fix(tranquil-store): commit-marker batch replay, batch-boundary rotation
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-21 14:55:15 +03:00
Lewis 9d81e58803 fix(tranquil-store): no more orphan data files, recover torn-hint tails, header-safe resume
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-21 09:59:37 +03:00
Lewis 2afd075496 fix(tranquil-store): atomic record commits, hint-as-truth recovery
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-20 21:00:54 +03:00
Hayden YoungandTangled c74bf967cf fix(auth): don't require 2fa for app passwords 2026-04-20 18:20:20 +03:00
Lewis 1285d5c675 fix(tranquil-store): bound writer fd usage across rotations
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-20 16:50:19 +03:00
Lewis c30d73cd4d test(tranquil-store): migrate some tests to gauntlet
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-19 23:50:27 +03:00
Lewis 0fab8f2eb9 feat(tranquil-store): tranquil-gauntlet CLI, config overrides, profiles
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-19 23:50:27 +03:00
Lewis 57336fa124 feat(tranquil-store/gauntlet): new invariants & scenarios
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-19 10:25:07 +03:00
Lewis ace105899f feat(tranquil-store/gauntlet): concurrent executor, eventlog, fault recovery
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-19 00:19:08 +03:00
Lewis c80a525e0d feat(tranquil-store/gauntlet): op surface, oracle, workload for eventlog & reads
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-18 10:36:30 +03:00
Lewis 7edb76507b feat(tranquil-store/gauntlet): simulated io fault modes, shrinker, regression dump
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-18 10:36:30 +03:00
LewisandTangled a20e4e05e6 fix(oauth): indigo client send empty assertions
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-17 15:18:26 +03:00
teqandTangled 3e7a199736 docs: nixos and binary cache 2026-04-17 01:32:30 +03:00
teqandTangled 7ed9c8ce59 feat(ci): supply cache by arg 2026-04-17 01:32:30 +03:00
teqandTangled b0c3402a82 feat(ci): add cachix publishing workflows 2026-04-17 01:32:30 +03:00
LewisandTangled b9574f3ec1 fix(lexicons): cache ttl
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-16 19:45:27 +03:00
isabelandTangled baef2be844 feat: allow max prefs to be configured
some people (me) have a large amount of prefs and moving from ref pds to
tranquil ment i lost a few of these. so in this pr i bumped the max
prefs to 1000 and made it configurable via the config
2026-04-16 19:45:17 +03:00
Lewis 1f21b85069 fix(example.toml): lewis forgot about confique
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-16 14:49:57 +03:00
Lewis 09d437b3e3 feat(tranquil-store): gauntlet persistence & restart invariants
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-15 22:28:31 +03:00
Lewis 7f2e83e92f feat(tranquil-store): beginnings of the gauntlet test suite
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-15 20:29:34 +03:00
nelind ec273fa814 feat(nix): cross compiled aarch64 package 2026-04-14 23:26:34 +02:00
Lewis d51bfd59da fix(tranquil-store): exclude 0 refcount blocks from has()
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-14 08:40:21 +03:00
LewisandTangled 8ccdd30cb3 fix(repo): use mst diff instead of full tree walk for obsolete blocks
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-13 17:43:07 +00:00
Lewis 7a67361993 fix(tranquil-store): checkpoint-hint race & missing dedup hints
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-13 11:10:17 +03:00
isabelandTangled cdbbaaccdf fix(nix/frontend): add nodejs 2026-04-12 23:08:39 +00:00
isabelandTangled 55d3b7f83d fix(nix/module): don't import using self 2026-04-12 22:50:00 +00:00
Gavin MoganandTangled f00b0231fb fix(Dockerfile): duplicate named stage causes failure to build 2026-04-12 18:53:21 +00:00
LewisandTangled 1dfbd27cce fix(postgres): semaphore on car endpoint & more efficient query
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-12 17:18:45 +00:00
isabelandTangled bc5e0e0446 build(frontend): use pnpm
deno is evil
2026-04-12 16:01:21 +00:00
Lewis 1c87ef5536 fix(tranquil-store): blockstore tweaks
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-12 17:35:59 +03:00
Lewis 255c7135f9 fix(auth): no bsky chat access when not specifically privileged to have it
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-12 15:56:17 +03:00
LewisandTangled 34f050a1f0 feat(oauth): discoverable passkey authentication
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-12 11:16:58 +00:00
LewisandTangled 52c22060f3 fix(tranquil-store): preserve refcount in hint relocate records
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-12 11:16:58 +00:00
Lewis c250d51978 chore(readme): another small update
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-11 17:23:43 +03:00
Lewis 378cb7a7fb chore(readme): update with contributers and new description of project
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-11 16:40:32 +03:00
Bas van den WollenbergandTangled bf20b4a78f fix: use correct postgres container path for quadlet as well 2026-04-11 07:45:23 +00:00
274 changed files with 35737 additions and 5728 deletions
+38
View File
@@ -25,6 +25,28 @@ fail-fast = false
test-threads = "num-cpus"
slow-timeout = { period = "300s", terminate-after = 2 }
[profile.gauntlet-pr]
retries = 0
fail-fast = true
test-threads = "num-cpus"
slow-timeout = { period = "60s", terminate-after = 5 }
[[profile.gauntlet-pr.overrides]]
filter = "binary(gauntlet_smoke)"
slow-timeout = { period = "300s", terminate-after = 2 }
[profile.gauntlet-nightly]
retries = 0
fail-fast = false
test-threads = "num-cpus"
slow-timeout = { period = "600s", terminate-after = 1 }
[profile.gauntlet-soak]
retries = 0
fail-fast = false
test-threads = 1
slow-timeout = { period = "5m", terminate-after = 1000 }
[test-groups]
serial-env-tests = { max-threads = 1 }
heavy-load-tests = { max-threads = 4 }
@@ -46,6 +68,10 @@ test-group = "serial-env-tests"
filter = "package(tranquil-signal)"
test-group = "serial-env-tests"
[[profile.default.overrides]]
filter = "package(tranquil-config)"
test-group = "serial-env-tests"
[[profile.default.overrides]]
filter = "binary(whole_story)"
test-group = "heavy-load-tests"
@@ -72,6 +98,14 @@ test-group = "io-heavy-sim"
filter = "test(/test_scale_/) | test(/full_backup_and_restore/)"
slow-timeout = { period = "120s", terminate-after = 4 }
[[profile.default.overrides]]
filter = "binary(gauntlet_smoke)"
slow-timeout = { period = "300s", terminate-after = 8 }
[[profile.default.overrides]]
filter = "binary(compaction_restart) | binary(mst_refcount_integrity) | binary(gc_compaction_restart)"
slow-timeout = { period = "120s", terminate-after = 4 }
[[profile.ci.overrides]]
filter = "test(/import_with_verification/) | test(/plc_migration/)"
test-group = "serial-env-tests"
@@ -88,6 +122,10 @@ test-group = "serial-env-tests"
filter = "package(tranquil-signal)"
test-group = "serial-env-tests"
[[profile.ci.overrides]]
filter = "package(tranquil-config)"
test-group = "serial-env-tests"
[[profile.ci.overrides]]
filter = "binary(whole_story)"
test-group = "heavy-load-tests"
+3 -1
View File
@@ -1,6 +1,8 @@
/target
target/
.env
.direnv
result
frontend/node_modules/
frontend/dist/
frontend/.pnpm-store
frontend/.npmrc
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1",
"query": "SELECT did, password_hash FROM users WHERE handle = $1 OR did = $1",
"describe": {
"columns": [
{
@@ -24,5 +24,5 @@
true
]
},
"hash": "c4621f6a8a1ab78a6355b09fdfc2bf8999d276564e93015792ec07cb05e79038"
"hash": "053c971024b0d29a441c3597d760b3e21db2383442c3e6f09de4eb49ea437e7c"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type as \"account_type!: AccountType\"\n FROM users\n WHERE handle = $1 OR email = $1\n ",
"query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type as \"account_type!: AccountType\"\n FROM users\n WHERE handle = $1 OR did = $1\n ",
"describe": {
"columns": [
{
@@ -118,5 +118,5 @@
false
]
},
"hash": "7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa"
"hash": "060c285c93a05252aab7d474df0186e7b5083fafedc582b8eac9916983e8fc2d"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin\n FROM users WHERE did = $1",
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration\n FROM users WHERE handle = $1",
"describe": {
"columns": [
{
@@ -42,6 +42,11 @@
"ordinal": 7,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "inbound_migration",
"type_info": "Bool"
}
],
"parameters": {
@@ -57,8 +62,9 @@
false,
true,
true,
false,
false
]
},
"hash": "6b51995c40519a63f85c70f29ca8bd6ec1963c8562d78215d980785dc46a6384"
"hash": "18bbda5582db1b32d02ab8a3eee970c9508b9bd67239c2f936639a9f863b30ff"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET deactivated_at = NULL WHERE did = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "23201d4e26bc650939e30f69fb0bca00d351d057098afebc1017f70a84b4bd22"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'discoverable'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "3155ef4f35698a3fe6aa38d5d976fd51b7f6a0381c81c4907dad61d2f37992bd"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET deactivated_at = NULL, inbound_migration = FALSE WHERE did = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "51e029e572777e6a103fd7fd5550494de9d4cac7e3ff84e27ddec1a6aaefc047"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE comms_queue\n SET status = 'failed'::comms_status,\n attempts = max_attempts,\n last_error = $2,\n updated_at = NOW()\n WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "5bee4ed5296667e4ca7e1a97aec28d30a470b8aee7b378ec9ca4e34de4faf349"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT state_json FROM webauthn_challenges\n WHERE did = $1 AND challenge_type = 'discoverable' AND expires_at > NOW()\n ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "state_json",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "6969c478a0922bac4b79902313a0e28c94d6b8d6b16035474dd8f484e6171d60"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE comms_queue\n SET status = 'processing', updated_at = NOW()\n WHERE id IN (\n SELECT id FROM comms_queue\n WHERE status = 'pending'\n AND scheduled_for <= $1\n AND attempts < max_attempts\n ORDER BY scheduled_for ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING\n id, user_id,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at",
"query": "UPDATE comms_queue\n SET status = 'processing', updated_at = NOW()\n WHERE id IN (\n SELECT id FROM comms_queue\n WHERE attempts < max_attempts\n AND scheduled_for <= $1\n AND (\n status = 'pending'\n OR (status = 'processing'\n AND updated_at < $1 - INTERVAL '10 minutes')\n )\n ORDER BY scheduled_for ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING\n id, user_id,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at",
"describe": {
"columns": [
{
@@ -154,5 +154,5 @@
true
]
},
"hash": "8047fda41bd94f819213decb8b3e0aba49a8dbdb10217eefd77e3567f8c9694a"
"hash": "890aa92acdcb0fe2a3bf04d87e1f16a801d271da7cedc32fc42c2ef5b100faae"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM oauth_token\n WHERE id IN (\n SELECT id FROM oauth_token\n WHERE did = $1\n ORDER BY updated_at ASC\n OFFSET $2\n )\n ",
"query": "\n DELETE FROM oauth_token\n WHERE id IN (\n SELECT id FROM oauth_token\n WHERE did = $1\n ORDER BY created_at DESC\n OFFSET $2\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -11,5 +11,5 @@
},
"nullable": []
},
"hash": "56cd24903171eddc2ededd9079ffe10937c34e99b0305f25c980ca754da44625"
"hash": "8f4357f7a18ddcf6b686a4555f244d37c35917364b8f917ca6ee2d4030ace742"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login, u.migrated_to_pds,\n u.preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,\n COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as \"email_2fa_enabled!\"\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1",
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login, u.migrated_to_pds,\n u.preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,\n COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as \"email_2fa_enabled!\"\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.did = $1",
"describe": {
"columns": [
{
@@ -132,5 +132,5 @@
null
]
},
"hash": "a960b981a146a0e422ef53601dfc31e29cf777aa194227c48c6ebc6905ea3249"
"hash": "aafc2a7e51200ca1e7071c63c13698bf34ef8b66758ca9ebab4ea706ffb62914"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)\n VALUES ($1, $2, $3, 'discoverable', $4, $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Bytea",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "c6e3388fc39983f1787917606ba3194c72322d2d1ec54402c262194791a2b06a"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin\n FROM users WHERE handle = $1",
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration\n FROM users WHERE did = $1",
"describe": {
"columns": [
{
@@ -42,6 +42,11 @@
"ordinal": 7,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "inbound_migration",
"type_info": "Bool"
}
],
"parameters": {
@@ -57,8 +62,9 @@
false,
true,
true,
false,
false
]
},
"hash": "584bceda60d8b6a02e7dc44d833e3fba13151f36ba9f75c64e33d6cb534cc939"
"hash": "f1c4ec28b02d09ffce35aa8249c1747a70c12a3ecfc1ff6ca1847840f770db2f"
}
@@ -0,0 +1,21 @@
when:
- event: ["push", "manual"]
branch: ["main"]
engine: nixery
dependencies:
nixpkgs:
- nix
- cachix
- gnugrep
steps:
- name: Authenticate
command: |
cachix authtoken "$CACHIX_AUTH_TOKEN"
- name: Build and push aarch64
command: |
SUBS="--option extra-substituters https://tranquil.cachix.org --option extra-trusted-public-keys tranquil.cachix.org-1:PoO+mGL6a6LcJiPakMDHN4E218/ei/7v2sxeDtNkSRg="
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-pds-aarch64 $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push tranquil
@@ -0,0 +1,28 @@
when:
- event: ["push", "manual"]
branch: ["main"]
engine: nixery
dependencies:
nixpkgs:
- nix
- cachix
- gnugrep
steps:
- name: Authenticate
command: |
cachix authtoken "$CACHIX_AUTH_TOKEN"
- name: Build and push x86_64
command: |
SUBS="--option extra-substituters https://tranquil.cachix.org --option extra-trusted-public-keys tranquil.cachix.org-1:PoO+mGL6a6LcJiPakMDHN4E218/ei/7v2sxeDtNkSRg="
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-pds $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push tranquil
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-frontend $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push tranquil
- name: Build and push devShell
command: |
SUBS="--option extra-substituters https://tranquil.cachix.org --option extra-trusted-public-keys tranquil.cachix.org-1:PoO+mGL6a6LcJiPakMDHN4E218/ei/7v2sxeDtNkSRg="
nix develop $SUBS --profile dev-profile -c true
cachix push tranquil dev-profile
+24 -12
View File
@@ -1,24 +1,36 @@
when:
- event: []
branch: []
- event: [ "manual" ]
- event: [ "push" ]
branch: [ "main" ]
engine: nixery
dependencies:
nixpkgs:
- podman
- kaniko
environment:
DOCKER_CONFIG: "/kaniko/.docker"
steps:
- name: Create podman config
- name: Configure Kaniko
command: |
mkdir -p ~/.config/containers
echo "unqualified-search-registries = [\"docker.io\"]" >> ~/.config/containers/registries.conf
mkdir -p /kaniko/.docker/
echo "{
\"auths\": {
\"https://atcr.io/v1\":{
\"auth\": \"$ATCR_CREDENTIALS\"
}
}
}" > /kaniko/.docker/config.json
- name: Build image
command: |
podman build . -t tranquil-pds:latest -t "tranquil-pds:$TANGLED_COMMIT_SHA"
- name: Publish image
command: |
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" tranquil-pds:latest "atcr.io/tranquil.farm/tranquil-pds:latest"
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" "tranquil-pds:$TANGLED_COMMIT_SHA" "atcr.io/tranquil.farm/tranquil-pds:$TANGLED_COMMIT_SHA"
executor \
--context=$(pwd) \
--ignore-path=$(pwd) \
--dockerfile=$(pwd)/Dockerfile \
--destination="atcr.io/tranquil.farm/tranquil-pds:latest" \
--destination="atcr.io/tranquil.farm/tranquil-pds:$TANGLED_COMMIT_SHA" \
--push-retry=3 \
--skip-push-permission-check
+135
View File
@@ -0,0 +1,135 @@
# Contributing to Tranquil PDS
## When PRing
In order of importance:
- **You must run your change! Every contribution that says "here's xyz. untested." does not help the project.**
- Relevant tests to your PR must pass. The whole suite doesn't have to be proven to have run, because there are a *ton* of tests and they're quite heavy, but hopefully there are existing tests for whatever you're PRing, and if there aren't, please add those too.
- Run cargo fmt :P
> 🦪 Lewis
>
> Good CI fixes some of these. We should really get around to that.
Things that would also be nice but aren't like, a pain in our side:
- Big changes should be stacked PRs that are broken up into digestible pieces. Those stacked PRs should hopefully be able to be merged individually if necessary.
## Local Development
### Prerequisites
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
- Add `pds.test` to your hosts file (one-time setup):
```
127.0.0.1 pds.test
```
- **macOS / Linux:** `/etc/hosts`
- **Windows:** `C:\Windows\System32\drivers\etc\hosts`
### Starting the dev environment
```bash
just run-dev
```
This starts the following services via `docker-compose`:
- **Traefik** — HTTPS reverse proxy at `https://pds.test`
- **Backend** — Rust server with `cargo-watch` (auto-rebuilds on file changes)
- **Frontend** — Vite dev server with hot module replacement
- **Postgres** — Database on port 5432
- **PLC Directory** — Local [did-method-plc](https://github.com/did-method-plc/did-method-plc) server for DID registration
- **Mailpit** — Local email server with web UI at [http://localhost:8025](http://localhost:8025)
Once all services are running, open **https://pds.test** in your browser.
### Trusting the self-signed certificate
Traefik generates a self-signed TLS certificate. Your browser will show a security warning on first visit. You can either click through it, or add the certificate to your system trust store for a seamless experience:
**macOS:**
```bash
# Extract the cert from traefik and add it to the system keychain
echo | openssl s_client -connect localhost:443 -servername pds.test 2>/dev/null | openssl x509 > /tmp/pds-test.pem
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /tmp/pds-test.pem
```
**Linux (Debian/Ubuntu):**
```bash
echo | openssl s_client -connect localhost:443 -servername pds.test 2>/dev/null | openssl x509 | sudo tee /usr/local/share/ca-certificates/pds-test.crt
sudo update-ca-certificates
```
**Linux (Fedora/RHEL):**
```bash
echo | openssl s_client -connect localhost:443 -servername pds.test 2>/dev/null | openssl x509 | sudo tee /etc/pki/ca-trust/source/anchors/pds-test.pem
sudo update-ca-trust
```
**Windows (PowerShell as Administrator):**
```powershell
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2
$cert.Import([System.Text.Encoding]::UTF8.GetBytes((echo | openssl s_client -connect localhost:443 -servername pds.test 2>$null | openssl x509)))
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store("Root", "LocalMachine")
$store.Open("ReadWrite")
$store.Add($cert)
$store.Close()
```
Restart your browser after adding the certificate.
### Stopping the dev environment
```bash
# Stop containers (preserves database + build cache)
docker compose --profile dev down
# Stop and wipe all data (fresh start)
docker compose --profile dev down -v
```
### Direct database access
Postgres is exposed on port 5432:
```bash
psql postgres://postgres:postgres@localhost:5432/pds
```
### How it works
- **Source code** is bind-mounted into the containers so that changes made on the host will be immediately reflected in the application
- **Backend** uses `cargo-watch` to recompile and restart when Rust files change
- **Frontend** uses Vite's HMR for instant browser updates when frontend files change
- **Build cache** (`target/` directory and cargo registry) are stored in Docker volumes, so incremental compilation persists across container restarts
- **Traefik** routes `/`, `/xrpc`, `/oauth`, `/.well-known`, `/u`, and `/health` to the backend; everything else goes to the Vite dev server
- **Mailpit** captures all outgoing email — open [http://localhost:8025](http://localhost:8025) to view verification emails during registration
- **PLC Directory** runs locally so DID registration doesn't hit the real `plc.directory`
### Running the backend natively
If you prefer running the Rust backend outside Docker (faster incremental builds on host), you need:
- Rust toolchain (see `rust-toolchain.toml`)
- `protoc` (`brew install protobuf` on macOS)
- PostgreSQL (start with `docker compose up db`)
Then run:
```bash
cargo run -p tranquil-server -- --config config.toml
```
And start the frontend separately:
```bash
cd frontend && pnpm install && pnpm dev
```
Generated
+181 -33
View File
@@ -9,7 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "087113bd50d9adce24850eed5d0476c7d199d532fce8fab5173650331e09033a"
dependencies = [
"abnf-core",
"nom",
"nom 7.1.3",
]
[[package]]
@@ -18,7 +18,7 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c44e09c43ae1c368fb91a03a566472d0087c26cf7e1b9e8e289c14ede681dd7d"
dependencies = [
"nom",
"nom 7.1.3",
]
[[package]]
@@ -213,7 +213,7 @@ dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom",
"nom 7.1.3",
"num-traits",
"rusticata-macros",
"thiserror 1.0.69",
@@ -1558,7 +1558,7 @@ checksum = "06b4f5ec222421e22bb0a8cbaa36b1d2b50fd45cdd30c915ded34108da78b29f"
dependencies = [
"confique-macro",
"serde",
"toml",
"toml 0.9.12+spec-1.1.0",
]
[[package]]
@@ -1972,7 +1972,7 @@ checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"nom 7.1.3",
"num-bigint",
"num-traits",
"rusticata-macros",
@@ -2216,6 +2216,22 @@ dependencies = [
"zeroize",
]
[[package]]
name = "email-encoding"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
dependencies = [
"base64 0.22.1",
"memchr",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]]
name = "embedded-io"
version = "0.4.0"
@@ -3780,6 +3796,37 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lettre"
version = "0.11.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7"
dependencies = [
"async-trait",
"base64 0.22.1",
"ed25519-dalek",
"email-encoding",
"email_address",
"fastrand",
"futures-io",
"futures-util",
"httpdate",
"idna",
"mime",
"nom 8.0.0",
"percent-encoding",
"quoted_printable",
"rsa",
"rustls 0.23.37",
"sha2",
"socket2 0.6.3",
"tokio",
"tokio-rustls 0.26.4",
"tracing",
"url",
"webpki-roots 1.0.6",
]
[[package]]
name = "libc"
version = "0.2.183"
@@ -4409,6 +4456,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "nonzero_ext"
version = "0.3.0"
@@ -4839,7 +4895,7 @@ checksum = "9114f9c1683dd09c5f4fa024c89fdad783eaae21d3d52dd23ddaaffa29ffb168"
dependencies = [
"either",
"fnv",
"nom",
"nom 7.1.3",
"once_cell",
"postcard",
"quick-xml",
@@ -5050,7 +5106,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
"toml_edit 0.25.5+spec-1.1.0",
]
[[package]]
@@ -5427,6 +5483,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "quoted_printable"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
[[package]]
name = "r-efi"
version = "5.3.0"
@@ -5833,7 +5895,7 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
"nom 7.1.3",
]
[[package]]
@@ -6050,6 +6112,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "secrecy"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
dependencies = [
"serde",
"zeroize",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -6201,6 +6273,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "1.0.4"
@@ -7144,6 +7225,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
@@ -7152,13 +7245,22 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap 2.13.0",
"serde_core",
"serde_spanned",
"serde_spanned 1.0.4",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
@@ -7177,6 +7279,20 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap 2.13.0",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_write",
"winnow 0.7.15",
]
[[package]]
name = "toml_edit"
version = "0.25.5+spec-1.1.0"
@@ -7198,6 +7314,12 @@ dependencies = [
"winnow 1.0.0",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.0.7+spec-1.1.0"
@@ -7405,7 +7527,7 @@ dependencies = [
[[package]]
name = "tranquil-api"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"anyhow",
"axum",
@@ -7456,7 +7578,7 @@ dependencies = [
[[package]]
name = "tranquil-auth"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"anyhow",
"base32",
@@ -7479,7 +7601,7 @@ dependencies = [
[[package]]
name = "tranquil-cache"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7493,11 +7615,19 @@ dependencies = [
[[package]]
name = "tranquil-comms"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"async-trait",
"base64 0.22.1",
"chrono",
"ed25519-dalek",
"futures",
"hickory-resolver",
"lettre",
"rand 0.8.5",
"reqwest",
"rsa",
"secrecy",
"serde_json",
"sqlx",
"thiserror 2.0.18",
@@ -7511,7 +7641,7 @@ dependencies = [
[[package]]
name = "tranquil-config"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"confique",
"serde",
@@ -7519,7 +7649,7 @@ dependencies = [
[[package]]
name = "tranquil-crypto"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"aes-gcm",
"base64 0.22.1",
@@ -7535,7 +7665,7 @@ dependencies = [
[[package]]
name = "tranquil-db"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"async-trait",
"chrono",
@@ -7552,7 +7682,7 @@ dependencies = [
[[package]]
name = "tranquil-db-traits"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7568,7 +7698,7 @@ dependencies = [
[[package]]
name = "tranquil-infra"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"async-trait",
"bytes",
@@ -7579,9 +7709,10 @@ dependencies = [
[[package]]
name = "tranquil-lexicon"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"chrono",
"futures",
"hickory-resolver",
"parking_lot",
"reqwest",
@@ -7597,7 +7728,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"anyhow",
"axum",
@@ -7620,7 +7751,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth-server"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"axum",
"base64 0.22.1",
@@ -7653,7 +7784,7 @@ dependencies = [
[[package]]
name = "tranquil-pds"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"aes-gcm",
"anyhow",
@@ -7693,6 +7824,7 @@ dependencies = [
"multibase",
"multihash",
"p256 0.13.2",
"parking_lot",
"rand 0.8.5",
"redis",
"regex",
@@ -7738,13 +7870,14 @@ dependencies = [
"urlencoding",
"uuid",
"webauthn-rs",
"webauthn-rs-proto",
"wiremock",
"zip",
]
[[package]]
name = "tranquil-repo"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"bytes",
"cid",
@@ -7756,7 +7889,7 @@ dependencies = [
[[package]]
name = "tranquil-ripple"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"async-trait",
"backon",
@@ -7781,7 +7914,7 @@ dependencies = [
[[package]]
name = "tranquil-scopes"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"axum",
"futures",
@@ -7797,15 +7930,24 @@ dependencies = [
[[package]]
name = "tranquil-server"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"arc-swap",
"axum",
"clap",
"dotenvy",
"ed25519-dalek",
"futures-util",
"hex",
"hyper 1.8.1",
"hyper-util",
"rustls 0.23.37",
"rustls-pemfile",
"thiserror 2.0.18",
"tokio",
"tokio-rustls 0.26.4",
"tokio-util",
"tower",
"tracing",
"tracing-subscriber",
"tranquil-api",
@@ -7818,7 +7960,7 @@ dependencies = [
[[package]]
name = "tranquil-signal"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"async-trait",
"chrono",
@@ -7841,7 +7983,7 @@ dependencies = [
[[package]]
name = "tranquil-storage"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"async-trait",
"aws-config",
@@ -7858,12 +8000,13 @@ dependencies = [
[[package]]
name = "tranquil-store"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"async-trait",
"bytes",
"chrono",
"cid",
"clap",
"dashmap",
"fjall",
"flume 0.11.1",
@@ -7871,6 +8014,7 @@ dependencies = [
"jacquard-common",
"jacquard-repo",
"k256",
"libc",
"lsm-tree",
"memmap2",
"multihash",
@@ -7890,6 +8034,7 @@ dependencies = [
"thiserror 2.0.18",
"tikv-jemallocator",
"tokio",
"toml 0.8.23",
"tracing",
"tracing-subscriber",
"tranquil-db",
@@ -7904,7 +8049,7 @@ dependencies = [
[[package]]
name = "tranquil-sync"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"anyhow",
"axum",
@@ -7926,7 +8071,7 @@ dependencies = [
[[package]]
name = "tranquil-types"
version = "0.5.0"
version = "0.6.3"
dependencies = [
"chrono",
"cid",
@@ -8441,7 +8586,7 @@ dependencies = [
"base64urlsafedata",
"der-parser",
"hex",
"nom",
"nom 7.1.3",
"openssl",
"openssl-sys",
"rand 0.9.2",
@@ -8851,6 +8996,9 @@ name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
@@ -9010,7 +9158,7 @@ dependencies = [
"data-encoding",
"der-parser",
"lazy_static",
"nom",
"nom 7.1.3",
"oid-registry",
"rusticata-macros",
"thiserror 1.0.69",
+12 -3
View File
@@ -26,7 +26,7 @@ members = [
]
[workspace.package]
version = "0.5.0"
version = "0.6.3"
edition = "2024"
license = "AGPL-3.0-or-later"
@@ -59,6 +59,7 @@ presage = { git = "https://github.com/whisperfish/presage", rev = "fe3ed54c4844a
unicode-segmentation = "1"
aes-gcm = "0.10"
arc-swap = "1"
backon = "1"
bincode = { version = "2", features = ["serde"] }
anyhow = "1.0"
@@ -86,6 +87,8 @@ hickory-resolver = { version = "0.24", features = ["tokio-runtime"] }
hkdf = "0.12"
hmac = "0.12"
http = "1.4"
hyper = { version = "1", features = ["server", "http1", "http2"] }
hyper-util = { version = "0.1", features = ["server", "server-auto", "server-graceful", "service", "tokio"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
qrcodegen = "1.8"
infer = "0.19"
@@ -93,6 +96,7 @@ ipld-core = "0.4"
iroh-car = "0.5"
jacquard-common = { version = "0.9", features = ["crypto-k256"] }
jacquard-repo = "0.9"
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls-tls", "pool", "dkim", "tracing"] }
jsonwebtoken = { version = "10.2", features = ["rust_crypto"] }
k256 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] }
metrics = "0.24"
@@ -105,6 +109,10 @@ p384 = { version = "0.13", features = ["ecdsa"] }
rand = "0.8"
redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] }
regex = "1"
rsa = "0.9"
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls-pemfile = "2"
secrecy = { version = "0.10", features = ["serde"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "http2", "charset", "macos-system-configuration"] }
serde = { version = "1.0", features = ["derive"] }
serde_bytes = "0.11"
@@ -116,8 +124,9 @@ sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid"
subtle = "2.5"
thiserror = "2.0"
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "time", "signal", "process", "io-util", "fs"] }
tokio-util = "0.7.18"
tokio-util = { version = "0.7.18", features = ["rt"] }
tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
totp-rs = { version = "5", features = ["qr"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "cors"] }
@@ -126,7 +135,7 @@ tracing = "0.1"
tracing-subscriber = "0.3"
urlencoding = "2.1"
uuid = { version = "1.19", features = ["v4", "v5", "v7", "fast-rng", "serde"] }
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] }
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys", "conditional-ui"] }
webauthn-rs-proto = "0.5"
zip = { version = "7.0", default-features = false, features = ["deflate"] }
+8 -7
View File
@@ -1,7 +1,10 @@
FROM denoland/deno:alpine AS frontend
FROM node:24-alpine AS frontend
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY frontend/ ./
RUN deno task build
RUN pnpm build
FROM rust:1.92-alpine AS builder
RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static mold clang protoc
@@ -32,7 +35,7 @@ COPY crates/tranquil-oauth-server ./crates/tranquil-oauth-server
COPY crates/tranquil-store ./crates/tranquil-store
COPY crates/tranquil-signal ./crates/tranquil-signal
COPY crates/tranquil-server ./crates/tranquil-server
COPY migrations ./crates/tranquil-pds/migrations
COPY migrations ./migrations
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
if [ "$SLIM" = "true" ]; then \
@@ -43,13 +46,11 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
cp target/release/tranquil-server /tmp/tranquil-pds
FROM alpine:3.23
RUN apk add --no-cache msmtp ca-certificates \
&& ln -sf /usr/bin/msmtp /usr/sbin/sendmail
RUN apk add --no-cache ca-certificates
COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds
COPY --from=frontend /app/dist /var/lib/tranquil-pds/frontend
COPY migrations /app/migrations
WORKDIR /app
ENV SERVER_HOST=0.0.0.0
ENV SERVER_HOST=[::]
ENV SERVER_PORT=3000
EXPOSE 3000
CMD ["tranquil-pds"]
+20
View File
@@ -0,0 +1,20 @@
FROM rust:1.92-alpine
RUN apk add --no-cache \
ca-certificates \
musl-dev \
pkgconfig \
openssl-dev \
openssl-libs-static \
mold \
clang \
protoc
RUN cargo install cargo-watch
ENV RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold"
ENV SQLX_OFFLINE=true
WORKDIR /app
CMD ["cargo", "watch", "-x", "run -p tranquil-server -- --config /app/config.dev.toml"]
+53 -15
View File
@@ -2,17 +2,25 @@
A Personal Data Server for the AT Protocol.
Bluesky runs on a federated protocol called AT Protocol. Your account lives on a PDS, a server that stores your posts, profile, follows, and cryptographic keys. Bluesky hosts one for you at bsky.social, but you can run your own. Self-hosting means you control your data; you're not dependent on any company's servers, and your account + data is actually yours.
"A what for the what?" -> glad you asked: Bluesky, Tangled, and a bunch of other web applications use a federated protocol called AT Protocol (atproto). Your account lives on a PDS, a server that stores your posts, profile, follows, cryptographic keys, et cetera. The beauty is that a PDS is the *only* place your data lives permanently - so you can navigate any atproto app knowing that your data is yours and not getting locked behind any one app's walls.
This particular PDS thrives under harsh conditions. It is a dandelion growing through the cracks in the sidewalk concrete.
We came together to make this PDS to enable and empower our users to better host their data on this shared protocol. All of our decisions as a project are guided by their usefulness to the community: PDS hosters and end-users both.
It has full compatibility with Bluesky's reference PDS.
Comparatively: Bluesky the company created a "reference PDS" that we can self-host quite easily, and that's great, but Bluesky has an incentive to make software for themselves first & foremost, then secondly their software can be useful for us self-hosters. In contrast, Tranquil is not from a company, and will never be.
## What's different about Tranquil PDS
It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), SSO login and signup, did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), and a built-in web UI for account management, repo browsing, and admin.
It is a superset of the reference PDS, including:
- passkeys and 2FA: WebAuthn/FIDO2, TOTP, backup codes, trusted devices
- SSO login and signup
- did:web support: PDS-hosted subdomains or bring-your-own
- multi-channel communication: you can be notified via email, discord, telegram, and signal for verification and alerts
- granular OAuth scopes with a consent UI that allows unchecking specific scopes
- app passwords with the same granular permission scope system as OAuth
- account delegation: letting others manage an account with configurable permission levels
- a built-in web UI for account management, repo browsing, and admin
The PDS itself is a single binary with no nodeJS runtime. However, at time of writing, Tranquil requires postgres running separately. Blobs are stored on the local filesystem by default (S3 optional). Valkey is also optional (as an alternative to the built-in cache).
Unlike the ref PDS, Tranquil is a single binary with no nodejs runtime. That said, at time of writing, Tranquil does require postgres running separately.
## Quick Start
@@ -38,33 +46,63 @@ just test
just lint
```
Nix users can enter a devshell with `nix develop`, or `direnv allow` to auto-enter via the bundled `.envrc`. Pre-built artifacts including the devshell are available from our [binary cache](docs/2_INSTALL_NIX.md#binary-cache).
## Production Deployment
### Quick Deploy (Docker/Podman Compose)
Edit `config.toml` with your values. Generate secrets with `openssl rand -base64 48`.
`docker-compose.prod.yaml` pulls the prebuilt image `atcr.io/tranquil.farm/tranquil-pds:latest`. Sign in to the registry first with `podman login atcr.io`. The Containers guide covers building from source.
```bash
cp example.toml config.toml
```
Edit `config.toml` with your values and generate secrets with `openssl rand -base64 48`. Set the postgres password to match `docker-compose.prod.yaml`. nginx needs a TLS certificate before it starts, so follow the wildcard cert steps in the [Containers guide](docs/2_INSTALL_CONTAINERS.md).
```bash
podman-compose -f docker-compose.prod.yaml up -d
```
### Installation Guides
- [Debian](docs/install-debian.md)
- [Containers](docs/install-containers.md)
- [Kubernetes](docs/install-kubernetes.md)
- [Nix](docs/2_INSTALL_NIX.md)
- [Containers](docs/2_INSTALL_CONTAINERS.md)
## Maintainers to ping
## Community
- [@lewis.moe](https://bsky.app/profile/did:plc:3fwecdnvtcscjnrx2p4n7alz)
- [@nel.pet](https://bsky.app/profile/did:plc:h5wsnqetncv6lu2weom35lg2)
### "Let's connect!" or whatever linkedin-types say
## Thanks
We currently don't have a shared space to chat and organize Tranquil things, but we're very interested in changing that in the near future. What do you suggest? Anything but a discord server.
This project is very grateful to [@nonbinary.computer](https://bsky.app/profile/did:plc:yfvwmnlztr4dwkb7hwz55r2g), [@juli.ee](https://bsky.app/profile/did:plc:7vimlesenouvuaqvle42yhvo), [@mary.my.id](https://bsky.app/profile/did:plc:ia76kvnndjutgedggx2ibrem), and [@baileytownsend.dev](https://bsky.app/profile/did:plc:rnpkyqnmsw4ipey6eotbdnnf) for their help and their code to lean on.
### Core team
- [@oyster.cafe](https://tangled.org/did:plc:3fwecdnvtcscjnrx2p4n7alz)
- [@nel.pet](https://tangled.org/did:plc:h5wsnqetncv6lu2weom35lg2)
### Amazing contributors
- [@isabelroses.com](https://tangled.org/did:plc:qxichs7jsycphrsmbujwqbfb)
- [@quilling.dev](https://tangled.org/did:plc:jrtgsidnmxaen4offglr5lsh)
- [@koi.rip](https://tangled.org/did:plc:b26ewgkrnx3yvsp2cdao3ntu)
- [@bas.sh](https://tangled.org/did:plc:c52wep6lj4sfbsqiz3yvb55h)
- [@nekomimi.pet](https://tangled.org/did:plc:ttdrpj45ibqunmfhdsb4zdwq)
- [@islacant.win](https://tangled.org/did:plc:aut6evcs6d6ngaunqgfhdzzu)
- [@a.starrysky.fyi](https://tangled.org/did:plc:uuyqs6y3pwtbteet4swt5i5y)
- [@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)
- Could be your name here too!
### Tranquil PDS instances in the wild!
- [Tranquil Farm](https://tranquil.farm)
- Your instance here!! Don't be a stranger.
### Special thanks
This project is very grateful to [@nonbinary.computer](https://tangled.org/did:plc:yfvwmnlztr4dwkb7hwz55r2g), [@juliet.paris](https://tangled.org/did:plc:hs3aly5l26pozymy4b6hz7ae), [@mary.my.id](https://tangled.org/did:plc:ia76kvnndjutgedggx2ibrem), [@baileytownsend.dev](https://tangled.org/did:plc:rnpkyqnmsw4ipey6eotbdnnf), and [@ptr.pet](https://tangled.org/did:plc:dfl62fgb7wtjj3fcbb72naae) for their help and their code to lean on.
## License
AGPL-3.0-or-later. Documentation is CC BY-SA 4.0. See [LICENSE](LICENSE) for details.
+30
View File
@@ -0,0 +1,30 @@
[server]
hostname = "pds.test"
allow_http_proxy = true
invite_code_required = false
disable_rate_limiting = true
[frontend]
enabled = true
dir = "/app/frontend/public"
[database]
url = "postgres://postgres:postgres@db:5432/pds"
[storage]
path = "/var/lib/tranquil-pds/blobs"
[plc]
directory_url = "http://plc:2582"
[email]
from_address = "noreply@pds.test"
from_name = "Tranquil PDS (Dev)"
[email.smarthost]
host = "mailpit"
port = 1025
tls = "none"
[secrets]
allow_insecure = true
+4 -3
View File
@@ -12,7 +12,6 @@ use tranquil_pds::auth::{Auth, NotTakendown, Permissive};
use tranquil_pds::state::AppState;
const APP_BSKY_NAMESPACE: &str = "app.bsky";
const MAX_PREFERENCES_COUNT: usize = 100;
const MAX_PREFERENCE_SIZE: usize = 10_000;
const PERSONAL_DETAILS_PREF: &str = "app.bsky.actor.defs#personalDetailsPref";
const DECLARED_AGE_PREF: &str = "app.bsky.actor.defs#declaredAgePref";
@@ -92,6 +91,8 @@ pub async fn put_preferences(
auth: Auth<NotTakendown>,
Json(input): Json<PutPreferencesInput>,
) -> Response {
let max_preferences_count: usize = tranquil_config::get().server.max_preferences_count;
let has_full_access = auth.permissions().has_full_access();
let user_id: uuid::Uuid = match state.repos.user.get_id_by_did(&auth.did).await {
Ok(Some(id)) => id,
@@ -99,11 +100,11 @@ pub async fn put_preferences(
return ApiError::InternalError(Some("User not found".into())).into_response();
}
};
if input.preferences.len() > MAX_PREFERENCES_COUNT {
if input.preferences.len() > max_preferences_count {
return ApiError::InvalidRequest(format!(
"Too many preferences: {} exceeds limit of {}",
input.preferences.len(),
MAX_PREFERENCES_COUNT
max_preferences_count
))
.into_response();
}
+4 -6
View File
@@ -9,7 +9,6 @@ use tranquil_pds::state::AppState;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SignalStatusOutput {
pub enabled: bool,
pub linked: bool,
}
@@ -23,13 +22,12 @@ pub async fn get_signal_status(
State(state): State<AppState>,
_auth: Auth<Admin>,
) -> Result<Json<SignalStatusOutput>, ApiError> {
let enabled = tranquil_config::get().signal.enabled;
let linked = match &state.signal_sender {
Some(slot) => slot.is_linked().await,
None => false,
};
Ok(Json(SignalStatusOutput { enabled, linked }))
Ok(Json(SignalStatusOutput { linked }))
}
pub async fn link_signal_device(
@@ -76,7 +74,7 @@ pub async fn link_signal_device(
let result = tokio::select! {
biased;
_ = shutdown.cancelled() => {
tracing::info!("Signal linking aborted due to server shutdown");
tracing::info!("server shutting down, aborting signal linking");
return;
}
r = link_result.completion => r,
@@ -84,10 +82,10 @@ pub async fn link_signal_device(
match result {
Ok(Ok(client)) => {
if slot_for_task.complete_link(generation, client).await {
tracing::info!("Signal device linked successfully");
tracing::info!("signal device linked");
} else {
tracing::warn!(
"Signal link completed but generation mismatch or already linked; discarding"
"discarding completed signal link, generation mismatch or already linked"
);
}
}
+13 -4
View File
@@ -231,10 +231,19 @@ pub async fn verify_credential(
app_passwords
.into_iter()
.find(|app| bcrypt::verify(password, &app.password_hash).unwrap_or(false))
.map(|app| CredentialMatch::AppPassword {
name: app.name,
scopes: app.scopes,
controller_did: app.created_by_controller_did,
.map(|app| {
let scopes = app.scopes.unwrap_or_else(|| {
if app.privilege.is_privileged() {
"transition:generic transition:chat.bsky".to_string()
} else {
"transition:generic".to_string()
}
});
CredentialMatch::AppPassword {
name: app.name,
scopes: Some(scopes),
controller_did: app.created_by_controller_did,
}
})
}
@@ -510,6 +510,7 @@ pub async fn create_account(
telegram_username: comms.telegram,
signal_username: comms.signal,
deactivated_at,
inbound_migration: is_migration || is_did_web_byod,
encrypted_key_bytes: repo.encrypted_key_bytes,
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
reserved_key_id,
@@ -282,7 +282,7 @@ async fn process_messaging_channel_update(
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
),
CommsChannel::Signal => ApiError::InvalidRequest(
"Invalid Signal username. Must be 3-32 characters followed by .XX (e.g. username.01)".into(),
"Invalid Signal username. Must be a 3-32 character nickname, a dot, then a 2-20 digit discriminator".into(),
),
CommsChannel::Email => ApiError::InvalidEmail,
});
+1 -1
View File
@@ -106,7 +106,7 @@ pub async fn import_repo(
.map(|c| c.import.skip_verification)
.unwrap_or(false)
});
let is_migration = user.deactivated_at.is_some();
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 {
+20 -21
View File
@@ -27,7 +27,6 @@ struct WriteAccumulator {
mst: Mst<TrackingBlockStore>,
results: Vec<WriteResult>,
ops: Vec<RecordOp>,
modified_keys: Vec<String>,
all_blob_cids: Vec<String>,
backlinks_to_add: Vec<Backlink>,
backlinks_to_remove: Vec<AtUri>,
@@ -44,7 +43,6 @@ async fn process_single_write(
mst,
mut results,
mut ops,
mut modified_keys,
mut all_blob_cids,
mut backlinks_to_add,
mut backlinks_to_remove,
@@ -69,8 +67,19 @@ async fn process_single_write(
.await?,
)
};
all_blob_cids.extend(extract_blob_cids(value));
let rkey = rkey.clone().unwrap_or_else(Rkey::generate);
let key = format!("{}/{}", collection, rkey);
if mst
.get(&key)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Failed to read MST: {e}"))))?
.is_some()
{
return Err(ApiError::InvalidRequest(format!(
"Record already exists at {key}"
)));
}
all_blob_cids.extend(extract_blob_cids(value));
let record_ipld = tranquil_pds::util::json_to_ipld(value);
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
@@ -78,8 +87,6 @@ async fn process_single_write(
.put(&record_bytes)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to store record".into())))?;
let key = format!("{}/{}", collection, rkey);
modified_keys.push(key.clone());
let new_mst = mst
.add(&key, record_cid)
.await
@@ -100,7 +107,6 @@ async fn process_single_write(
mst: new_mst,
results,
ops,
modified_keys,
all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
@@ -124,16 +130,7 @@ async fn process_single_write(
.await?,
)
};
all_blob_cids.extend(extract_blob_cids(value));
let record_ipld = tranquil_pds::util::json_to_ipld(value);
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
let record_cid = tracking_store
.put(&record_bytes)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to store record".into())))?;
let key = format!("{}/{}", collection, rkey);
modified_keys.push(key.clone());
let prev_record_cid = mst
.get(&key)
.await
@@ -143,6 +140,14 @@ async fn process_single_write(
.ok_or_else(|| {
ApiError::InvalidRequest("Update target record does not exist".into())
})?;
all_blob_cids.extend(extract_blob_cids(value));
let record_ipld = tranquil_pds::util::json_to_ipld(value);
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
let record_cid = tracking_store
.put(&record_bytes)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to store record".into())))?;
let new_mst = mst
.update(&key, record_cid)
.await
@@ -165,7 +170,6 @@ async fn process_single_write(
mst: new_mst,
results,
ops,
modified_keys,
all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
@@ -173,7 +177,6 @@ async fn process_single_write(
}
WriteOp::Delete { collection, rkey } => {
let key = format!("{}/{}", collection, rkey);
modified_keys.push(key.clone());
let prev_record_cid = mst
.get(&key)
.await
@@ -198,7 +201,6 @@ async fn process_single_write(
mst: new_mst,
results,
ops,
modified_keys,
all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
@@ -219,7 +221,6 @@ async fn process_writes(
mst: initial_mst,
results: Vec::new(),
ops: Vec::new(),
modified_keys: Vec::new(),
all_blob_cids: Vec::new(),
backlinks_to_add: Vec::new(),
backlinks_to_remove: Vec::new(),
@@ -351,7 +352,6 @@ pub async fn apply_writes(
mst: final_mst,
results,
ops,
modified_keys,
all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
@@ -407,7 +407,6 @@ pub async fn apply_writes(
controller_did: controller_did.as_ref(),
delegation_detail: write_summary,
ops,
modified_keys: &modified_keys,
blob_cids: &all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
@@ -74,7 +74,6 @@ pub async fn delete_record(
prev: RecordCid::from(prev_record_cid),
};
let modified_keys = [key];
let deleted_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
let commit_result = finalize_repo_write(
@@ -93,7 +92,6 @@ pub async fn delete_record(
})
}),
ops: vec![op],
modified_keys: &modified_keys,
blob_cids: &[],
backlinks_to_add: vec![],
backlinks_to_remove: vec![deleted_uri],
+12 -19
View File
@@ -179,6 +179,18 @@ pub async fn create_record(
}
}
let key = format!("{}/{}", input.collection, rkey);
if mst
.get(&key)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Failed to read MST: {e}"))))?
.is_some()
{
return Err(ApiError::InvalidRequest(format!(
"Record already exists at {key}"
)));
}
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()))?;
@@ -187,8 +199,6 @@ pub async fn create_record(
.put(&record_bytes)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to save record block".into())))?;
let key = format!("{}/{}", input.collection, rkey);
mst = mst
.add(&key, record_cid)
.await
@@ -200,20 +210,6 @@ pub async fn create_record(
cid: tranquil_pds::cid_types::RecordCid::from(record_cid),
});
let modified_keys: Vec<String> = ops
.iter()
.map(|op| match op {
RecordOp::Create {
collection, rkey, ..
}
| RecordOp::Update {
collection, rkey, ..
}
| RecordOp::Delete {
collection, rkey, ..
} => format!("{}/{}", collection, rkey),
})
.collect();
let blob_cids = extract_blob_cids(&input.record);
let created_uri = AtUri::from_parts(&did, &input.collection, &rkey);
@@ -235,7 +231,6 @@ pub async fn create_record(
})
}),
ops,
modified_keys: &modified_keys,
blob_cids: &blob_cids,
backlinks_to_add,
backlinks_to_remove: conflict_uris_to_cleanup,
@@ -367,7 +362,6 @@ pub async fn put_record(
}
};
let modified_keys = [key];
let blob_cids = extract_blob_cids(&input.record);
let backlinks_to_add = extract_backlinks(&record_uri, &input.record);
@@ -387,7 +381,6 @@ pub async fn put_record(
})
}),
ops: vec![op],
modified_keys: &modified_keys,
blob_cids: &blob_cids,
backlinks_to_add,
backlinks_to_remove,
@@ -132,7 +132,14 @@ pub async fn create_app_password(
};
(scope_result, Some(controller.clone()))
} else {
(input.scopes.clone(), None)
let scopes = match input.scopes {
Some(ref s) => s.clone(),
None => match input.privileged {
Some(false) => "transition:generic".to_string(),
_ => "transition:generic transition:chat.bsky".to_string(),
},
};
(Some(scopes), None)
};
let password = generate_app_password();
+6 -4
View File
@@ -5,7 +5,7 @@ use tranquil_pds::BUILD_VERSION;
use tranquil_pds::state::AppState;
use tranquil_pds::util::{discord_app_id, discord_bot_username, telegram_bot_username};
fn get_available_comms_channels() -> Vec<CommsChannel> {
async fn get_available_comms_channels(state: &AppState) -> Vec<CommsChannel> {
let cfg = tranquil_config::get();
let mut channels = vec![CommsChannel::Email];
if cfg.discord.bot_token.is_some() {
@@ -14,7 +14,9 @@ fn get_available_comms_channels() -> Vec<CommsChannel> {
if cfg.telegram.bot_token.is_some() {
channels.push(CommsChannel::Telegram);
}
if cfg.signal.enabled {
if let Some(slot) = &state.signal_sender
&& slot.is_linked().await
{
channels.push(CommsChannel::Signal);
}
channels
@@ -66,7 +68,7 @@ pub struct DescribeServerOutput {
pub telegram_bot_username: Option<String>,
}
pub async fn describe_server() -> Json<DescribeServerOutput> {
pub async fn describe_server(State(state): State<AppState>) -> Json<DescribeServerOutput> {
let cfg = tranquil_config::get();
let pds_hostname = &cfg.server.hostname;
@@ -82,7 +84,7 @@ pub async fn describe_server() -> Json<DescribeServerOutput> {
email: cfg.server.contact_email.clone(),
},
version: BUILD_VERSION,
available_comms_channels: get_available_comms_channels(),
available_comms_channels: get_available_comms_channels(&state).await,
self_hosted_did_web_enabled: is_self_hosted_did_web_enabled(),
discord_bot_username: discord_bot_username().map(String::from),
discord_app_id: discord_app_id().map(String::from),
+1
View File
@@ -51,6 +51,7 @@ pub use session::{
auto_resend_verification, confirm_signup, create_session, delete_session,
get_legacy_login_preference, get_session, list_sessions, refresh_session, resend_verification,
revoke_all_sessions, revoke_session, update_legacy_login_preference, update_locale,
verification_blocks_login,
};
pub use signing_key::reserve_signing_key;
pub use totp::{
@@ -401,7 +401,7 @@ pub async fn create_passkey_account(
refresh_expires_at: refresh_expires,
login_type: tranquil_db_traits::LoginType::Modern,
mfa_verified: false,
scope: Some("transition:generic".to_string()),
scope: Some("transition:generic transition:chat.bsky".to_string()),
controller_did: None,
app_password_name: None,
};
+10 -3
View File
@@ -8,7 +8,7 @@ use bcrypt::verify;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, info, warn};
use tranquil_db_traits::{SessionId, TokenFamilyId};
use tranquil_db_traits::{ChannelVerificationStatus, SessionId, TokenFamilyId};
use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::api::{EmptyResponse, PreferredLocaleOutput, SuccessResponse};
use tranquil_pds::auth::{
@@ -20,6 +20,13 @@ use tranquil_pds::state::AppState;
use tranquil_pds::types::{AccountState, Did, Handle, PlainPassword};
use tranquil_types::TokenId;
pub fn verification_blocks_login(channel_verification: &ChannelVerificationStatus) -> bool {
!tranquil_config::get()
.server
.disable_account_verification_gate
&& !channel_verification.has_any_verified()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateSessionInput {
@@ -129,14 +136,13 @@ pub async fn create_session(
warn!("Login attempt for takendown account: {}", row.did);
return Err(ApiError::AccountTakedown);
}
let is_verified = row.channel_verification.has_any_verified();
let is_delegated = state
.repos
.delegation
.is_delegated_account(&row.did)
.await
.unwrap_or(false);
if !is_verified && !is_delegated {
if verification_blocks_login(&row.channel_verification) && !is_delegated {
warn!("Login attempt for unverified account: {}", row.did);
let resend_info = auto_resend_verification(&state, &row.did).await;
let handle = resend_info
@@ -163,6 +169,7 @@ pub async fn create_session(
let email_2fa_enabled = row.email_2fa_enabled;
let is_legacy_login = has_totp || email_2fa_enabled;
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,
+11
View File
@@ -10,7 +10,14 @@ tranquil-signal = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
ed25519-dalek = { workspace = true }
futures = { workspace = true }
hickory-resolver = { workspace = true }
lettre = { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true }
rsa = { workspace = true }
secrecy = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
thiserror = { workspace = true }
@@ -18,3 +25,7 @@ tokio = { workspace = true }
tracing = { workspace = true }
tranquil-db-traits = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
chrono = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time", "io-util", "net"] }
+227
View File
@@ -0,0 +1,227 @@
use std::fs;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use ed25519_dalek::pkcs8::DecodePrivateKey as _;
use lettre::Message;
use lettre::message::dkim::{
DkimCanonicalization, DkimCanonicalizationType, DkimConfig as LettreDkimConfig,
DkimSigningAlgorithm, DkimSigningKey,
};
use lettre::message::header::HeaderName;
use rsa::pkcs1::EncodeRsaPrivateKey;
use rsa::pkcs8::LineEnding;
use super::types::{DkimKeyPath, DkimSelector, EmailDomain};
use crate::sender::SendError;
const SIGNED_HEADERS: &[&str] = &[
"From",
"Sender",
"Reply-To",
"To",
"Cc",
"Subject",
"Date",
"In-Reply-To",
"References",
"MIME-Version",
"Content-Type",
"Content-Transfer-Encoding",
];
pub struct DkimSigner {
config: LettreDkimConfig,
}
impl DkimSigner {
pub fn load(
selector: DkimSelector,
domain: EmailDomain,
path: DkimKeyPath,
) -> Result<Self, SendError> {
let pem = fs::read_to_string(path.as_path()).map_err(|e| {
SendError::DkimSign(format!("read DKIM key {}: {e}", path.as_path().display()))
})?;
Self::from_pem(selector, domain, &pem)
}
pub fn from_pem(
selector: DkimSelector,
domain: EmailDomain,
pem: &str,
) -> Result<Self, SendError> {
let key = parse_key(pem)?;
let canonicalization = DkimCanonicalization {
header: DkimCanonicalizationType::Relaxed,
body: DkimCanonicalizationType::Relaxed,
};
let headers = SIGNED_HEADERS
.iter()
.copied()
.map(HeaderName::new_from_ascii_str)
.collect();
let config = LettreDkimConfig::new(
selector.into_inner(),
domain.into_inner(),
key,
headers,
canonicalization,
);
Ok(Self { config })
}
pub fn sign(&self, message: &mut Message) {
message.sign(&self.config);
}
}
impl std::fmt::Debug for DkimSigner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("DkimSigner")
}
}
fn parse_key(input: &str) -> Result<DkimSigningKey, SendError> {
let trimmed = input.trim_start();
match trimmed {
s if s.starts_with("-----BEGIN RSA PRIVATE KEY-----") => {
DkimSigningKey::new(input, DkimSigningAlgorithm::Rsa)
.map_err(|e| SendError::DkimSign(format!("RSA PKCS#1 PEM rejected: {e}")))
}
s if s.starts_with("-----BEGIN PRIVATE KEY-----") => parse_pkcs8(input),
s if s.starts_with("-----BEGIN") => Err(SendError::DkimSign(
"unrecognized PEM type; expected an RSA or Ed25519 private key".to_string(),
)),
_ => DkimSigningKey::new(input.trim(), DkimSigningAlgorithm::Ed25519).map_err(|e| {
SendError::DkimSign(format!(
"expected base64-encoded 32-byte Ed25519 seed or a PEM-wrapped key: {e}"
))
}),
}
}
fn parse_pkcs8(pem: &str) -> Result<DkimSigningKey, SendError> {
let ed25519_err = match ed25519_dalek::SigningKey::from_pkcs8_pem(pem) {
Ok(key) => {
let seed = BASE64_STANDARD.encode(key.to_bytes());
return DkimSigningKey::new(&seed, DkimSigningAlgorithm::Ed25519)
.map_err(|e| SendError::DkimSign(format!("re-import Ed25519 seed: {e}")));
}
Err(e) => e,
};
let rsa_err = match rsa::RsaPrivateKey::from_pkcs8_pem(pem) {
Ok(key) => {
let pkcs1 = key
.to_pkcs1_pem(LineEnding::LF)
.map_err(|e| SendError::DkimSign(format!("re-encode RSA PKCS#8 as PKCS#1: {e}")))?;
return DkimSigningKey::new(pkcs1.as_str(), DkimSigningAlgorithm::Rsa)
.map_err(|e| SendError::DkimSign(format!("re-import RSA PKCS#1: {e}")));
}
Err(e) => e,
};
Err(SendError::DkimSign(format!(
"PKCS#8 PEM rejected by both parsers; ed25519: {ed25519_err}; rsa: {rsa_err}"
)))
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::pkcs8::EncodePrivateKey as _;
use lettre::message::Mailbox;
use lettre::message::header::ContentType;
use rsa::pkcs1::DecodeRsaPrivateKey as _;
const ED25519_RAW_SEED_B64: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
const RSA_PKCS1_PEM: &str = include_str!("test_fixtures/rsa2048-priv-pkcs1.pem");
fn ed25519_pkcs8_pem() -> String {
let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string()
}
fn rsa_pkcs8_pem() -> String {
let key = rsa::RsaPrivateKey::from_pkcs1_pem(RSA_PKCS1_PEM).unwrap();
key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string()
}
fn signer(pem: &str) -> DkimSigner {
DkimSigner::from_pem(
DkimSelector::parse("default").unwrap(),
EmailDomain::parse("nel.pet").unwrap(),
pem,
)
.expect("key should load")
}
fn signed_headers(signer: &DkimSigner) -> String {
let from: Mailbox = "sender@nel.pet".parse().unwrap();
let to: Mailbox = "recipient@nel.pet".parse().unwrap();
let mut message = Message::builder()
.from(from)
.to(to)
.subject("Roundtrip")
.header(ContentType::TEXT_PLAIN)
.body("Body".to_string())
.unwrap();
signer.sign(&mut message);
String::from_utf8(message.formatted()).unwrap()
}
#[test]
fn rejects_garbage() {
assert!(matches!(
parse_key("not a key"),
Err(SendError::DkimSign(_))
));
}
#[test]
fn rejects_unknown_pem_type() {
let pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----\n";
match parse_key(pem) {
Err(SendError::DkimSign(msg)) => assert!(msg.contains("unrecognized"), "msg: {msg}"),
other => panic!("expected unrecognized PEM error, got {other:?}"),
}
}
#[test]
fn ed25519_raw_seed_signs() {
let raw = signed_headers(&signer(ED25519_RAW_SEED_B64));
assert_signed_with(&raw, "a=ed25519-sha256");
}
#[test]
fn ed25519_pkcs8_pem_signs() {
let raw = signed_headers(&signer(&ed25519_pkcs8_pem()));
assert_signed_with(&raw, "a=ed25519-sha256");
}
#[test]
fn rsa_pkcs1_pem_signs() {
let raw = signed_headers(&signer(RSA_PKCS1_PEM));
assert_signed_with(&raw, "a=rsa-sha256");
}
#[test]
fn rsa_pkcs8_pem_signs() {
let raw = signed_headers(&signer(&rsa_pkcs8_pem()));
assert_signed_with(&raw, "a=rsa-sha256");
}
fn assert_signed_with(raw: &str, algorithm: &str) {
assert!(
raw.contains("DKIM-Signature:"),
"no signature header: {raw}"
);
assert!(raw.contains(algorithm), "missing {algorithm}: {raw}");
assert!(
raw.contains("c=relaxed/relaxed"),
"expected relaxed/relaxed canonicalization: {raw}"
);
}
}
+151
View File
@@ -0,0 +1,151 @@
use lettre::Message;
use lettre::message::Mailbox;
use lettre::message::header::ContentType;
use uuid::Uuid;
use super::types::EmailDomain;
use crate::sender::SendError;
use crate::types::QueuedComms;
pub(super) fn build(from: &Mailbox, qc: &QueuedComms) -> Result<Message, SendError> {
let to: Mailbox = qc
.recipient
.parse()
.map_err(|e: lettre::address::AddressError| SendError::InvalidRecipient(e.to_string()))?;
let subject = qc.subject.as_deref().unwrap_or("Notification");
let message_id = format!("<{}@{}>", Uuid::new_v4(), from.email.domain());
Message::builder()
.from(from.clone())
.to(to)
.subject(subject)
.message_id(Some(message_id))
.header(ContentType::TEXT_PLAIN)
.body(qc.body.clone())
.map_err(|e| SendError::MessageBuild(e.to_string()))
}
pub(super) fn recipient_domain(message: &Message) -> Result<EmailDomain, SendError> {
let envelope = message.envelope();
let first = envelope
.to()
.first()
.ok_or_else(|| SendError::MessageBuild("envelope has no recipients".to_string()))?;
EmailDomain::parse(first.domain())
.map_err(|e| SendError::InvalidRecipient(format!("invalid recipient domain: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{CommsChannel, CommsStatus, CommsType};
use chrono::Utc;
use uuid::Uuid;
fn from_mailbox() -> Mailbox {
"Test Sender <noreply@nel.pet>".parse().unwrap()
}
fn fixture(recipient: &str, subject: Option<&str>, body: &str) -> QueuedComms {
QueuedComms {
id: Uuid::new_v4(),
user_id: None,
channel: CommsChannel::Email,
comms_type: CommsType::Welcome,
status: CommsStatus::Pending,
recipient: recipient.to_string(),
subject: subject.map(String::from),
body: body.to_string(),
metadata: None,
attempts: 0,
max_attempts: 3,
last_error: None,
created_at: Utc::now(),
updated_at: Utc::now(),
scheduled_for: Utc::now(),
processed_at: None,
}
}
#[test]
fn build_basic_message() {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("Welcome"), "Hello world."),
)
.unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
let lower = raw.to_lowercase();
assert!(raw.contains("From: \"Test Sender\" <noreply@nel.pet>"));
assert!(raw.contains("To: user@nel.pet"));
assert!(raw.contains("Subject: Welcome"));
assert!(lower.contains("content-type: text/plain"));
assert!(raw.contains("Hello world."));
}
#[test]
fn utf8_subject_is_encoded() {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("héllo wörld"), "Body"),
)
.unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(raw.contains("=?utf-8?"));
assert!(!raw.contains("héllo"));
}
#[test]
fn header_injection_rejected() {
let result = build(
&from_mailbox(),
&fixture("x@nel.pet\r\nBcc: evil@x", Some("s"), "b"),
);
assert!(matches!(result, Err(SendError::InvalidRecipient(_))));
}
#[test]
fn subject_crlf_does_not_inject_headers() {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("hi\r\nBcc: evil@nel.pet"), "body"),
)
.expect("subject CRLF should be encoded, not rejected");
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(
!raw.contains("Bcc:"),
"CRLF in subject must not produce a Bcc header: {raw}"
);
assert!(
raw.contains("Subject: ="),
"subject with non-printable chars should be RFC 2047 encoded: {raw}"
);
}
#[test]
fn message_id_uses_from_domain() {
let msg = build(&from_mailbox(), &fixture("user@nel.pet", Some("s"), "b")).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
let line = raw
.lines()
.find(|l| l.starts_with("Message-ID:") || l.starts_with("Message-Id:"))
.expect("message-id header present");
assert!(
line.contains("@nel.pet>"),
"message-id should use From domain: {line}"
);
}
#[test]
fn missing_subject_uses_default() {
let msg = build(&from_mailbox(), &fixture("user@nel.pet", None, "Body")).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(raw.contains("Subject: Notification"));
}
#[test]
fn recipient_domain_extracted() {
let msg = build(&from_mailbox(), &fixture("user@Nel.PET", Some("s"), "b")).unwrap();
let d = recipient_domain(&msg).unwrap();
assert_eq!(d.as_str(), "nel.pet");
}
}
+194
View File
@@ -0,0 +1,194 @@
pub mod dkim;
pub mod message;
mod mx;
pub mod transport;
pub mod types;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use hickory_resolver::TokioAsyncResolver;
use lettre::message::Mailbox;
use lettre::transport::smtp::AsyncSmtpTransport;
use lettre::transport::smtp::PoolConfig;
use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::extension::ClientId;
use tokio::sync::Semaphore;
use tracing::{info, warn};
pub use self::dkim::DkimSigner;
pub use self::transport::SendMode;
use self::types::{
DkimKeyPath, DkimSelector, EmailDomain, HeloName, SmtpHost, SmtpPassword, SmtpPort,
SmtpUsername, TlsMode,
};
use crate::sender::{CommsSender, SendError};
use crate::types::{CommsChannel, QueuedComms};
pub struct EmailSender {
from: Mailbox,
mode: SendMode,
dkim: Option<DkimSigner>,
}
impl EmailSender {
pub fn new(from: Mailbox, mode: SendMode, dkim: Option<DkimSigner>) -> Self {
Self { from, mode, dkim }
}
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Result<Option<Self>, SendError> {
let Some(from_address) = cfg.email.from_address.as_deref().filter(|s| !s.is_empty()) else {
info!("Email sender disabled: MAIL_FROM_ADDRESS unset");
return Ok(None);
};
let from = build_from(&cfg.email.from_name, from_address)?;
let dkim = build_dkim(&cfg.email.dkim)?;
let mode = match cfg
.email
.smarthost
.host
.as_deref()
.filter(|h| !h.is_empty())
{
Some(host) => build_smarthost(cfg, host)?,
None => build_direct_mx(cfg)?,
};
info!(?mode, dkim = dkim.is_some(), "Email sender initialized");
Ok(Some(Self { from, mode, dkim }))
}
}
fn config_invalid(field: &str, error: impl std::fmt::Display) -> SendError {
SendError::ConfigInvalid(format!("{field}: {error}"))
}
fn build_from(from_name: &str, from_address: &str) -> Result<Mailbox, SendError> {
let raw = match from_name.is_empty() {
true => from_address.to_string(),
false => format!("\"{}\" <{}>", from_name.replace('"', "'"), from_address),
};
raw.parse::<Mailbox>()
.map_err(|e| config_invalid("MAIL_FROM_ADDRESS / MAIL_FROM_NAME", e))
}
fn build_smarthost(
cfg: &tranquil_config::TranquilConfig,
host_raw: &str,
) -> Result<SendMode, SendError> {
let host = SmtpHost::parse(host_raw).map_err(|e| config_invalid("MAIL_SMARTHOST_HOST", e))?;
let port = SmtpPort::parse(cfg.email.smarthost.port)
.map_err(|e| config_invalid("MAIL_SMARTHOST_PORT", e))?;
let tls = TlsMode::parse(&cfg.email.smarthost.tls)
.map_err(|e| config_invalid("MAIL_SMARTHOST_TLS", e))?;
let helo = resolve_helo(cfg)?;
let pool = PoolConfig::new()
.max_size(cfg.email.smarthost.pool_size)
.idle_timeout(Duration::from_secs(60));
let command_timeout = Duration::from_secs(cfg.email.smarthost.command_timeout_secs);
let total_timeout = Duration::from_secs(cfg.email.smarthost.total_timeout_secs);
let builder = match tls {
TlsMode::Implicit => AsyncSmtpTransport::<lettre::Tokio1Executor>::relay(host.as_str())
.map_err(|e| config_invalid("smarthost TLS setup", e))?,
TlsMode::Starttls => {
AsyncSmtpTransport::<lettre::Tokio1Executor>::starttls_relay(host.as_str())
.map_err(|e| config_invalid("smarthost TLS setup", e))?
}
TlsMode::None => {
AsyncSmtpTransport::<lettre::Tokio1Executor>::builder_dangerous(host.as_str())
}
};
let builder = builder
.port(port.as_u16())
.hello_name(ClientId::Domain(helo.into_inner()))
.timeout(Some(command_timeout))
.pool_config(pool);
let builder = match (
cfg.email.smarthost.username.as_deref(),
cfg.email.smarthost.password.as_deref(),
) {
(Some(u), Some(p)) => {
let username =
SmtpUsername::parse(u).map_err(|e| config_invalid("MAIL_SMARTHOST_USERNAME", e))?;
let password =
SmtpPassword::parse(p).map_err(|e| config_invalid("MAIL_SMARTHOST_PASSWORD", e))?;
builder.credentials(Credentials::new(
username.into_inner(),
password.expose().to_string(),
))
}
_ => builder,
};
Ok(SendMode::Smarthost {
transport: Box::new(builder.build()),
total_timeout,
})
}
fn build_direct_mx(cfg: &tranquil_config::TranquilConfig) -> Result<SendMode, SendError> {
let helo = resolve_helo(cfg)?;
let resolver = TokioAsyncResolver::tokio_from_system_conf()
.map(Arc::new)
.map_err(|e| config_invalid("system DNS configuration", e))?;
let max_concurrent = cfg.email.direct_mx.max_concurrent_sends.max(1);
Ok(SendMode::DirectMx {
resolver,
helo,
command_timeout: Duration::from_secs(cfg.email.direct_mx.command_timeout_secs),
total_timeout: Duration::from_secs(cfg.email.direct_mx.total_timeout_secs),
require_tls: cfg.email.direct_mx.require_tls,
inflight: Arc::new(Semaphore::new(max_concurrent)),
})
}
fn resolve_helo(cfg: &tranquil_config::TranquilConfig) -> Result<HeloName, SendError> {
let raw = cfg
.email
.helo_name
.clone()
.unwrap_or_else(|| cfg.server.hostname_without_port().to_string());
HeloName::parse(&raw).map_err(|e| config_invalid(&format!("HELO name {raw:?}"), e))
}
fn build_dkim(cfg: &tranquil_config::DkimConfig) -> Result<Option<DkimSigner>, SendError> {
let selector = match cfg.selector.as_deref() {
Some(s) => s,
None => return Ok(None),
};
let domain = cfg
.domain
.as_deref()
.ok_or_else(|| SendError::DkimSign("MAIL_DKIM_DOMAIN required when selector set".into()))?;
let key_path = cfg.private_key_path.as_deref().ok_or_else(|| {
SendError::DkimSign("MAIL_DKIM_KEY_PATH required when selector set".into())
})?;
let selector = DkimSelector::parse(selector)
.map_err(|e| SendError::DkimSign(format!("invalid DKIM selector: {e}")))?;
let domain = EmailDomain::parse(domain)
.map_err(|e| SendError::DkimSign(format!("invalid DKIM domain: {e}")))?;
let path = DkimKeyPath::parse(key_path)
.map_err(|e| SendError::DkimSign(format!("DKIM key path invalid: {e}")))?;
DkimSigner::load(selector, domain, path).map(Some)
}
#[async_trait]
impl CommsSender for EmailSender {
fn channel(&self) -> CommsChannel {
CommsChannel::Email
}
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
let mut message = message::build(&self.from, notification)?;
if let Some(signer) = &self.dkim {
signer.sign(&mut message);
}
match transport::dispatch(&self.mode, message).await {
Ok(()) => Ok(()),
Err(e) => {
warn!(comms_id = %notification.id, error = %e, "SMTP send failed");
Err(e)
}
}
}
}
+250
View File
@@ -0,0 +1,250 @@
use hickory_resolver::TokioAsyncResolver;
use hickory_resolver::error::{ResolveError, ResolveErrorKind};
use hickory_resolver::proto::op::ResponseCode;
use rand::seq::SliceRandom;
use super::types::{EmailDomain, MxHost, MxPriority, MxRecord};
use crate::sender::SendError;
pub async fn resolve(
resolver: &TokioAsyncResolver,
domain: &EmailDomain,
) -> Result<Vec<MxRecord>, SendError> {
match resolver.mx_lookup(domain.as_str()).await {
Ok(lookup) => interpret_lookup(
lookup
.iter()
.map(|mx| (mx.preference(), mx.exchange().clone())),
domain,
),
Err(e) => classify_lookup_error(e, domain),
}
}
fn interpret_lookup(
items: impl IntoIterator<Item = (u16, hickory_resolver::Name)>,
domain: &EmailDomain,
) -> Result<Vec<MxRecord>, SendError> {
let entries: Vec<_> = items.into_iter().collect();
match entries.iter().any(|(_, name)| name.is_root()) {
true => Err(SendError::DnsPermanent(format!(
"null MX record at {}: domain refuses mail",
domain.as_str()
))),
false => {
let records: Vec<MxRecord> = entries
.into_iter()
.filter_map(|(prio, name)| {
MxHost::parse(&name.to_utf8()).ok().map(|host| MxRecord {
priority: MxPriority::new(prio),
host,
})
})
.collect();
match records.is_empty() {
true => implicit_mx(domain),
false => Ok(prioritize(records)),
}
}
}
}
fn prioritize(mut records: Vec<MxRecord>) -> Vec<MxRecord> {
records.shuffle(&mut rand::thread_rng());
records.sort_by_key(|r| r.priority);
records
}
fn classify_lookup_error(
e: ResolveError,
domain: &EmailDomain,
) -> Result<Vec<MxRecord>, SendError> {
match e.kind() {
ResolveErrorKind::NoRecordsFound { response_code, .. } => match *response_code {
ResponseCode::NoError => implicit_mx(domain),
ResponseCode::NXDomain => Err(SendError::DnsPermanent(format!(
"domain {} does not exist",
domain.as_str()
))),
other => Err(SendError::DnsTransient(format!(
"MX lookup for {} failed with {other}",
domain.as_str()
))),
},
_ => Err(SendError::DnsTransient(e.to_string())),
}
}
fn implicit_mx(domain: &EmailDomain) -> Result<Vec<MxRecord>, SendError> {
MxHost::parse(domain.as_str())
.map(|host| {
vec![MxRecord {
priority: MxPriority::new(0),
host,
}]
})
.map_err(|e| SendError::DnsPermanent(format!("invalid recipient domain: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn record(prio: u16, host: &str) -> MxRecord {
MxRecord {
priority: MxPriority::new(prio),
host: MxHost::parse(host).unwrap(),
}
}
#[test]
fn prioritize_sorts_by_priority_ascending() {
let result = prioritize(vec![
record(20, "mx2.nel.pet"),
record(10, "mx1.nel.pet"),
record(10, "mx1b.nel.pet"),
]);
assert_eq!(result[0].priority.as_u16(), 10);
assert_eq!(result[1].priority.as_u16(), 10);
assert_eq!(result[2].priority.as_u16(), 20);
}
#[test]
fn prioritize_randomizes_equal_priority_order() {
let attempts: Vec<Vec<String>> = (0..200)
.map(|_| {
prioritize(vec![
record(10, "a.nel.pet"),
record(10, "b.nel.pet"),
record(10, "c.nel.pet"),
record(10, "d.nel.pet"),
])
.into_iter()
.map(|r| r.host.as_str().to_string())
.collect()
})
.collect();
let distinct: std::collections::HashSet<_> = attempts.iter().cloned().collect();
assert!(
distinct.len() > 1,
"equal-priority MX order should vary across calls; got only {}",
distinct.len()
);
}
#[test]
fn implicit_mx_uses_domain_as_host() {
let d = EmailDomain::parse("nel.pet").unwrap();
let result = implicit_mx(&d).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].priority.as_u16(), 0);
assert_eq!(result[0].host.as_str(), "nel.pet");
}
#[test]
fn no_error_response_yields_implicit_mx() {
let d = EmailDomain::parse("nel.pet").unwrap();
let err = ResolveError::from(ResolveErrorKind::NoRecordsFound {
query: Box::new(hickory_resolver::proto::op::Query::default()),
soa: None,
negative_ttl: None,
response_code: ResponseCode::NoError,
trusted: false,
});
let result = classify_lookup_error(err, &d).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].host.as_str(), "nel.pet");
}
#[test]
fn nxdomain_response_is_permanent() {
let d = EmailDomain::parse("does-not-exist.invalid").unwrap();
let err = ResolveError::from(ResolveErrorKind::NoRecordsFound {
query: Box::new(hickory_resolver::proto::op::Query::default()),
soa: None,
negative_ttl: None,
response_code: ResponseCode::NXDomain,
trusted: true,
});
match classify_lookup_error(err, &d) {
Err(SendError::DnsPermanent(_)) => {}
other => panic!("expected DnsPermanent, got {other:?}"),
}
}
#[test]
fn servfail_response_is_transient() {
let d = EmailDomain::parse("nel.pet").unwrap();
let err = ResolveError::from(ResolveErrorKind::NoRecordsFound {
query: Box::new(hickory_resolver::proto::op::Query::default()),
soa: None,
negative_ttl: None,
response_code: ResponseCode::ServFail,
trusted: false,
});
match classify_lookup_error(err, &d) {
Err(SendError::DnsTransient(_)) => {}
other => panic!("expected DnsTransient, got {other:?}"),
}
}
#[test]
fn timeout_is_transient() {
let d = EmailDomain::parse("nel.pet").unwrap();
let err = ResolveError::from(ResolveErrorKind::Timeout);
match classify_lookup_error(err, &d) {
Err(SendError::DnsTransient(_)) => {}
other => panic!("expected DnsTransient, got {other:?}"),
}
}
#[test]
fn message_variant_is_transient() {
let d = EmailDomain::parse("nel.pet").unwrap();
let err = ResolveError::from(ResolveErrorKind::Message("transient resolver glitch"));
match classify_lookup_error(err, &d) {
Err(SendError::DnsTransient(_)) => {}
other => panic!("expected DnsTransient default, got {other:?}"),
}
}
#[test]
fn null_mx_is_permanent() {
let d = EmailDomain::parse("nomail.nel.pet").unwrap();
let result = interpret_lookup(vec![(0, hickory_resolver::Name::root())], &d);
match result {
Err(SendError::DnsPermanent(msg)) => {
assert!(msg.contains("null MX"), "msg: {msg}")
}
other => panic!("expected DnsPermanent, got {other:?}"),
}
}
#[test]
fn null_mx_alongside_real_records_still_permanent() {
let d = EmailDomain::parse("mixed.nel.pet").unwrap();
let real = hickory_resolver::Name::from_ascii("mx1.nel.pet.").unwrap();
let result = interpret_lookup(vec![(10, real), (0, hickory_resolver::Name::root())], &d);
assert!(matches!(result, Err(SendError::DnsPermanent(_))));
}
#[test]
fn empty_lookup_uses_implicit_mx() {
let d = EmailDomain::parse("nel.pet").unwrap();
let result = interpret_lookup(Vec::<(u16, hickory_resolver::Name)>::new(), &d).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].host.as_str(), "nel.pet");
}
#[test]
fn valid_records_pass_through_with_priority_sort() {
let d = EmailDomain::parse("nel.pet").unwrap();
let mx1 = hickory_resolver::Name::from_ascii("mx1.nel.pet.").unwrap();
let mx2 = hickory_resolver::Name::from_ascii("mx2.nel.pet.").unwrap();
let result = interpret_lookup(vec![(20, mx2), (10, mx1)], &d).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].priority.as_u16(), 10);
assert_eq!(result[0].host.as_str(), "mx1.nel.pet");
assert_eq!(result[1].priority.as_u16(), 20);
}
}
@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAtsQsUV8QpqrygsY+2+JCQ6Fw8/omM71IM2N/R8pPbzbgOl0p
78MZGsgPOQ2HSznjD0FPzsH8oO2B5Uftws04LHb2HJAYlz25+lN5cqfHAfa3fgmC
38FfwBkn7l582UtPWZ/wcBOnyCgb3yLcvJrXyrt8QxHJgvWO23ITrUVYszImbXQ6
7YGS0YhMrbixRzmo2tpm3JcIBtnHrEUMsT0NfFdfsZhTT8YbxBvA8FdODgEwx7u/
vf3J9qbi4+Kv8cvqyJuleIRSjVXPsIMnoejIn04APPKIjpMyQdnWlby7rNyQtE4+
CV+jcFjqJbE/Xilcvqxt6DirjFCvYeKYl1uHLwIDAQABAoIBAH7Mg2LA7bB0EWQh
XiL3SrnZG6BpAHAM9jaQ5RFNjua9z7suP5YUaSpnegg/FopeUuWWjmQHudl8bg5A
ZPgtoLdYoU8XubfUH19I4o1lUXBPVuaeeqn6Yw/HZCjAbSXkVdz8VbesK092ZD/e
0/4V/3irsn5lrMSq0L322yfvYKaRDFxKCF7UMnWrGcHZl6Msbv/OffLRk19uYB7t
4WGhK1zCfKIfgdLJnD0eoI6Q4wU6sJvvpyTe8NDDo8HpdAwNn3YSahSewKp9gHgg
VIQlTZUdsHxM+R+2RUwJZYj9WSTbq+s1nKICUmjQBPnWbrPW963BE5utQPFt3mOe
EWRzdsECgYEA3MBhJC1Okq+u5yrFE8plufdwNvm9fg5uYUYafvdlQiXsFTx+XDGm
FXpuWhP/bheOh1jByzPZ1rvjF57xiZjkIuzcvtePTs/b5fT82K7CydDchkc8qb0W
2dI40h+13e++sUPKYdC9aqjZHzOgl3kOlkDbyRCF3F8mNDujE49rLWcCgYEA0/MU
dX5A6VSDb5K+JCNq8vDaBKNGU8GAr2fpYAhtk/3mXLI+/Z0JN0di9ZgeNhhJr2jN
11OU/2pOButpsgnkIo2y36cOQPf5dQpSgXZke3iNDld3osuLIuPNJn/3C087AtOq
+w4YxZClZLAxiLCqX8SBVrB2IiFCQ70SJ++n8vkCgYEAzmi3rBsNEA1jblVIh1PF
wJhD/bOQ4nBd92iUV8m9jZdl4wl4YX4u/IBI9MMkIG24YIe2VOl7s9Rk5+4/jNg/
4QQ2998Y6aljxOZJEdZ+3jQELy4m49OhrTRq2ta5t/Z3CMsJTmLe6f9NXWZpr5iK
8iVdHOjtMXxqfYaR2jVNEtsCgYAl9uWUQiAoa037v0I1wO5YQ9IZgJGJUSDWynsg
C4JtPs5zji4ASY+sCipsqWnH8MPKGrC8QClxMr51ONe+30yw78a5jvfbpU9Wqpmq
vOU0xJwnlH1GeMUcY8eMfOFocjG0yOtYeubvBIDLr0/AFzz9WHp+Z69RX7m53nUR
GDlyKQKBgDGZVAbUBiB8rerqNbONBAxfipoa4IJ+ntBrFT2DtoIZNbSzaoK+nVbH
kbWMJycaV5PVOh1lfAiZeWCxQz5RcZh/RS8USnxyMG1j4dP/wLcbdasI8uRaSC6Y
hFHL5HjhLrIo0HRWySS2b2ztBI2FP1M+MaaGFPHDzm2OyZg85yr3
-----END RSA PRIVATE KEY-----
@@ -0,0 +1,164 @@
use std::sync::Arc;
use std::time::Duration;
use futures::StreamExt;
use hickory_resolver::TokioAsyncResolver;
use lettre::transport::smtp::AsyncSmtpTransport;
use lettre::transport::smtp::Error as SmtpError;
use lettre::transport::smtp::client::{Tls, TlsParameters};
use lettre::transport::smtp::extension::ClientId;
use lettre::{AsyncTransport, Message, Tokio1Executor};
use tokio::sync::Semaphore;
use super::message::recipient_domain;
use super::mx;
use super::types::{HeloName, MxRecord};
use crate::sender::SendError;
pub enum SendMode {
Smarthost {
transport: Box<AsyncSmtpTransport<Tokio1Executor>>,
total_timeout: Duration,
},
DirectMx {
resolver: Arc<TokioAsyncResolver>,
helo: HeloName,
command_timeout: Duration,
total_timeout: Duration,
require_tls: bool,
inflight: Arc<Semaphore>,
},
}
impl std::fmt::Debug for SendMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Smarthost { total_timeout, .. } => {
write!(f, "SendMode::Smarthost(total_timeout={total_timeout:?})")
}
Self::DirectMx {
helo, require_tls, ..
} => write!(
f,
"SendMode::DirectMx({}, require_tls={require_tls})",
helo.as_str()
),
}
}
}
pub async fn dispatch(mode: &SendMode, message: Message) -> Result<(), SendError> {
match mode {
SendMode::Smarthost {
transport,
total_timeout,
} => with_total_timeout(*total_timeout, run_send(transport, message)).await,
SendMode::DirectMx {
resolver,
helo,
command_timeout,
total_timeout,
require_tls,
inflight,
} => {
with_total_timeout(*total_timeout, async {
let _permit =
inflight.clone().acquire_owned().await.map_err(|_| {
SendError::SmtpTransient("send semaphore closed".to_string())
})?;
send_direct(
resolver.as_ref(),
helo,
*command_timeout,
*require_tls,
message,
)
.await
})
.await
}
}
}
async fn with_total_timeout<F: std::future::Future<Output = Result<(), SendError>>>(
total: Duration,
fut: F,
) -> Result<(), SendError> {
tokio::time::timeout(total, fut)
.await
.unwrap_or(Err(SendError::Timeout))
}
async fn run_send(
transport: &AsyncSmtpTransport<Tokio1Executor>,
message: Message,
) -> Result<(), SendError> {
transport
.send(message)
.await
.map(|_| ())
.map_err(classify_smtp_error)
}
async fn send_direct(
resolver: &TokioAsyncResolver,
helo: &HeloName,
command_timeout: Duration,
require_tls: bool,
message: Message,
) -> Result<(), SendError> {
let domain = recipient_domain(&message)?;
let mxs = mx::resolve(resolver, &domain).await?;
let outcome = futures::stream::iter(mxs)
.fold(None::<Result<(), SendError>>, |acc, mx_record| {
let message = message.clone();
async move {
match &acc {
Some(Ok(())) | Some(Err(SendError::SmtpPermanent(_))) => acc,
_ => Some(
attempt_one_host(mx_record, helo, command_timeout, require_tls, message)
.await,
),
}
}
})
.await;
outcome.unwrap_or_else(|| {
Err(SendError::SmtpTransient(format!(
"no MX records returned for {}",
domain.as_str()
)))
})
}
async fn attempt_one_host(
mx_record: MxRecord,
helo: &HeloName,
command_timeout: Duration,
require_tls: bool,
message: Message,
) -> Result<(), SendError> {
let host = mx_record.host.as_str().to_string();
let tls_params = TlsParameters::new(host.clone())
.map_err(|e| SendError::SmtpTransient(format!("TLS params for {host}: {e}")))?;
let tls = match require_tls {
true => Tls::Required(tls_params),
false => Tls::Opportunistic(tls_params),
};
let transport: AsyncSmtpTransport<Tokio1Executor> =
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host)
.port(25)
.tls(tls)
.hello_name(ClientId::Domain(helo.as_str().to_string()))
.timeout(Some(command_timeout))
.build();
run_send(&transport, message).await
}
fn classify_smtp_error(e: SmtpError) -> SendError {
match () {
_ if e.is_permanent() => SendError::SmtpPermanent(e.to_string()),
_ if e.is_timeout() => SendError::Timeout,
_ => SendError::SmtpTransient(e.to_string()),
}
}
+291
View File
@@ -0,0 +1,291 @@
use std::path::PathBuf;
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
#[error("empty value")]
Empty,
#[error("invalid character {0:?}")]
InvalidChar(char),
#[error("zero {0}")]
Zero(&'static str),
#[error("invalid TLS mode {0:?}")]
InvalidTlsMode(String),
}
fn parse_token(raw: &str, lowercase: bool, strip_trailing_dot: bool) -> Result<String, ParseError> {
let mut s = raw.trim();
if strip_trailing_dot {
s = s.trim_end_matches('.');
}
match s {
"" => Err(ParseError::Empty),
_ if s.chars().any(char::is_whitespace) => Err(ParseError::InvalidChar(' ')),
_ => Ok(match lowercase {
true => s.to_lowercase(),
false => s.to_string(),
}),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SmtpHost(String);
impl SmtpHost {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
parse_token(raw, true, false).map(Self)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SmtpPort(u16);
impl SmtpPort {
pub fn parse(raw: u16) -> Result<Self, ParseError> {
match raw {
0 => Err(ParseError::Zero("smtp port")),
n => Ok(Self(n)),
}
}
pub fn as_u16(self) -> u16 {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct HeloName(String);
impl HeloName {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
parse_token(raw, false, false).map(Self)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct EmailDomain(String);
impl EmailDomain {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
parse_token(raw, true, true).map(Self)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MxHost(String);
impl MxHost {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
parse_token(raw, true, true).map(Self)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct MxPriority(u16);
impl MxPriority {
pub fn new(value: u16) -> Self {
Self(value)
}
pub fn as_u16(self) -> u16 {
self.0
}
}
#[derive(Debug, Clone)]
pub struct MxRecord {
pub priority: MxPriority,
pub host: MxHost,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DkimSelector(String);
impl DkimSelector {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
let trimmed = raw.trim();
let valid = !trimmed.is_empty() && trimmed.split('.').all(valid_subdomain);
match valid {
true => Ok(Self(trimmed.to_string())),
false => Err(ParseError::InvalidChar('?')),
}
}
pub fn into_inner(self) -> String {
self.0
}
}
fn valid_subdomain(seg: &str) -> bool {
let starts_alnum = seg
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric());
let ends_alnum = seg
.chars()
.next_back()
.is_some_and(|c| c.is_ascii_alphanumeric());
let body_ok = seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
starts_alnum && ends_alnum && body_ok
}
#[derive(Debug, Clone)]
pub struct DkimKeyPath(PathBuf);
impl DkimKeyPath {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
let trimmed = raw.trim();
match trimmed.is_empty() {
true => Err(ParseError::Empty),
false => Ok(Self(PathBuf::from(trimmed))),
}
}
pub fn as_path(&self) -> &std::path::Path {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SmtpUsername(String);
impl SmtpUsername {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
match raw.is_empty() {
true => Err(ParseError::Empty),
false => Ok(Self(raw.to_string())),
}
}
pub fn into_inner(self) -> String {
self.0
}
}
#[derive(Clone)]
pub struct SmtpPassword(secrecy::SecretString);
impl SmtpPassword {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
match raw.is_empty() {
true => Err(ParseError::Empty),
false => Ok(Self(secrecy::SecretString::from(raw.to_string()))),
}
}
pub fn expose(&self) -> &str {
use secrecy::ExposeSecret;
self.0.expose_secret()
}
}
impl std::fmt::Debug for SmtpPassword {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SmtpPassword(***)")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TlsMode {
Implicit,
Starttls,
None,
}
impl TlsMode {
pub fn parse(raw: &str) -> Result<Self, ParseError> {
match raw.to_ascii_lowercase().as_str() {
"implicit" => Ok(Self::Implicit),
"starttls" => Ok(Self::Starttls),
"none" => Ok(Self::None),
other => Err(ParseError::InvalidTlsMode(other.to_string())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn smtp_host_lowercases_and_trims() {
let h = SmtpHost::parse(" SMTP.NEL.PET ").unwrap();
assert_eq!(h.as_str(), "smtp.nel.pet");
}
#[test]
fn smtp_host_rejects_whitespace() {
assert!(SmtpHost::parse("a b").is_err());
}
#[test]
fn smtp_host_rejects_empty() {
assert!(SmtpHost::parse("").is_err());
assert!(SmtpHost::parse(" ").is_err());
}
#[test]
fn smtp_port_rejects_zero() {
assert!(SmtpPort::parse(0).is_err());
assert_eq!(SmtpPort::parse(587).unwrap().as_u16(), 587);
}
#[test]
fn email_domain_strips_trailing_dot() {
assert_eq!(EmailDomain::parse("Nel.pet.").unwrap().as_str(), "nel.pet");
}
#[test]
fn dkim_selector_validates() {
assert!(DkimSelector::parse("default").is_ok());
assert!(DkimSelector::parse("s1.nel.pet").is_ok());
assert!(DkimSelector::parse("s2024-q1").is_ok());
assert!(DkimSelector::parse("mailo-2024.nel.pet").is_ok());
assert!(DkimSelector::parse("a-b").is_ok());
assert!(DkimSelector::parse("").is_err());
assert!(DkimSelector::parse("a..b").is_err());
assert!(DkimSelector::parse("-leading").is_err());
assert!(DkimSelector::parse("trailing-").is_err());
assert!(DkimSelector::parse("s_under").is_err());
}
#[test]
fn tls_mode_parses_known_modes() {
assert_eq!(TlsMode::parse("STARTTLS").unwrap(), TlsMode::Starttls);
assert_eq!(TlsMode::parse("implicit").unwrap(), TlsMode::Implicit);
assert_eq!(TlsMode::parse("none").unwrap(), TlsMode::None);
assert!(TlsMode::parse("garbage").is_err());
}
#[test]
fn smtp_password_redacts_in_debug() {
let p = SmtpPassword::parse("hunter2").unwrap();
let dbg = format!("{:?}", p);
assert_eq!(dbg, "SmtpPassword(***)");
assert!(!dbg.contains("hunter2"));
}
}
+4 -2
View File
@@ -1,13 +1,15 @@
pub mod email;
mod locale;
mod sender;
mod types;
pub use email::EmailSender;
pub use locale::{
DEFAULT_LOCALE, NotificationStrings, VALID_LOCALES, format_message, get_strings,
validate_locale,
};
pub use sender::{
CommsSender, DiscordSender, EmailSender, SendError, SignalSender, TelegramSender,
is_valid_phone_number, is_valid_signal_username, mime_encode_header, sanitize_header_value,
CommsSender, DiscordSender, SendError, SignalSender, TelegramSender, is_valid_phone_number,
is_valid_signal_username,
};
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
+37 -1
View File
@@ -1,5 +1,5 @@
pub const DEFAULT_LOCALE: &str = "en";
pub const VALID_LOCALES: &[&str] = &["en", "zh", "ja", "ko", "sv", "fi"];
pub const VALID_LOCALES: &[&str] = &["en", "zh", "ja", "ko", "sv", "fi", "fr"];
pub fn validate_locale(locale: &str) -> &str {
if VALID_LOCALES.contains(&locale) {
@@ -44,6 +44,7 @@ pub fn get_strings(locale: &str) -> &'static NotificationStrings {
"ko" => &STRINGS_KO,
"sv" => &STRINGS_SV,
"fi" => &STRINGS_FI,
"fr" => &STRINGS_FR,
_ => &STRINGS_EN,
}
}
@@ -216,6 +217,34 @@ static STRINGS_FI: NotificationStrings = NotificationStrings {
channel_verification_body: "Vahvistuskoodisi on:\n{code}\n\nTai vahvista suoraan:\n{verify_link}",
};
static STRINGS_FR: NotificationStrings = NotificationStrings {
welcome_subject: "Bienvenue sur {hostname}",
welcome_body: "Bienvenue sur {hostname} !\n\nVotre identifiant est : @{handle}\n\nMerci de nous avoir rejoint.",
password_reset_subject: "Réinitialisation du mot de passe - {hostname}",
password_reset_body: "Bonjour @{handle},\n\nVotre code de réinitialisation du mot de passe est : {code}\n\nCe code expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, veuillez ignorer ce message.",
email_update_subject: "Confirmer votre nouvelle adresse e-mail - {hostname}",
email_update_body: "Bonjour @{handle},\n\nVotre code de vérification est :\n{code}\n\nCopiez le code ci-dessus et saisissez-le ici :\n{verify_page}\n\nCe code expirera dans 10 minutes.\n\nOu si vous aimez vivre dangereusement :\n{verify_link}\n\nSi vous n'avez pas demandé cela, veuillez ignorer cet e-mail.",
short_token_body: "Bonjour @{handle},\n\nVotre code de vérification est :\n{code}\n\nCe code expirera dans 15 minutes.\n\nSi vous n'avez pas demandé cela, veuillez ignorer cet e-mail.",
account_deletion_subject: "Demande de suppression de compte - {hostname}",
account_deletion_body: "Bonjour @{handle},\n\nVotre code de confirmation de suppression de compte est : {code}\n\nCe code expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, sécurisez votre compte immédiatement.",
plc_operation_subject: "{hostname} - Jeton d'opération PLC",
plc_operation_body: "Bonjour @{handle},\n\nVous avez demandé à signer une opération PLC pour votre compte.\n\nVotre jeton de vérification est : {token}\n\nCe jeton expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, vous pouvez ignorer ce message en toute sécurité.",
two_factor_code_subject: "Vérification de connexion - {hostname}",
two_factor_code_body: "Bonjour @{handle},\n\nVotre code de vérification de connexion est : {code}\n\nCe code expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, sécurisez votre compte immédiatement.",
passkey_recovery_subject: "Récupération de compte - {hostname}",
passkey_recovery_body: "Bonjour @{handle},\n\nVous avez demandé la récupération de votre compte à clé d'accès uniquement.\n\nCliquez sur le lien ci-dessous pour définir un mot de passe temporaire et retrouver l'accès :\n{url}\n\nCe lien expirera dans 1 heure.\n\nSi vous n'avez pas demandé cela, veuillez ignorer ce message. Votre compte reste sécurisé.",
signup_verification_subject: "Vérifier votre compte - {hostname}",
signup_verification_body: "Bienvenue ! Votre code de vérification est :\n{code}\n\nCopiez le code ci-dessus et saisissez-le ici :\n{verify_page}\n\nCe code expirera dans 30 minutes.\n\nOu si vous aimez vivre dangereusement :\n{verify_link}\n\nSi vous n'avez pas créé de compte sur {hostname}, veuillez ignorer ce message.",
legacy_login_subject: "Alerte de sécurité : Connexion classique détectée - {hostname}",
legacy_login_body: "Bonjour @{handle},\n\nUne connexion à votre compte a été détectée via une application classique (comme Bluesky) qui ne prend pas en charge la vérification TOTP.\n\nDétails :\n- Date : {timestamp}\n- Adresse IP : {ip}\n\nVotre protection TOTP a été contournée pour cette connexion. La session dispose de permissions limitées pour les opérations sensibles.\n\nSi ce n'était pas vous :\n1. Changez votre mot de passe immédiatement\n2. Vérifiez vos sessions actives\n3. Envisagez de désactiver les connexions d'applications classiques dans vos paramètres de sécurité\n\nRestez vigilant,\n{hostname}",
migration_verification_subject: "Vérifier votre adresse e-mail - {hostname}",
migration_verification_body: "Bienvenue sur {hostname} !\n\nVotre compte a été migré avec succès. Pour finaliser la configuration, veuillez vérifier votre adresse e-mail.\n\nVotre code de vérification est :\n{code}\n\nCopiez le code ci-dessus et saisissez-le ici :\n{verify_page}\n\nCe code expirera dans 48 heures.\n\nOu si vous aimez vivre dangereusement :\n{verify_link}\n\nSi vous n'avez pas migré votre compte, veuillez ignorer cet e-mail.",
channel_verified_subject: "Canal de notification vérifié - {hostname}",
channel_verified_body: "Bonjour {handle},\n\n{channel} a été vérifié comme canal de notification pour votre compte sur {hostname}.",
channel_verification_subject: "Vérifier votre canal - {hostname}",
channel_verification_body: "Votre code de vérification est :\n{code}\n\nOu vérifiez directement :\n{verify_link}",
};
pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String {
vars.iter()
.fold(template.to_string(), |result, (key, value)| {
@@ -233,6 +262,9 @@ mod tests {
assert_eq!(validate_locale("zh"), "zh");
assert_eq!(validate_locale("ja"), "ja");
assert_eq!(validate_locale("ko"), "ko");
assert_eq!(validate_locale("sv"), "sv");
assert_eq!(validate_locale("fi"), "fi");
assert_eq!(validate_locale("fr"), "fr");
assert_eq!(validate_locale("invalid"), DEFAULT_LOCALE);
assert_eq!(validate_locale(""), DEFAULT_LOCALE);
}
@@ -252,5 +284,9 @@ mod tests {
let zh = get_strings("zh");
assert!(zh.welcome_subject.contains("{hostname}"));
assert!(zh.welcome_body.contains("欢迎"));
let fr = get_strings("fr");
assert!(fr.welcome_subject.contains("{hostname}"));
assert!(fr.welcome_body.contains("Bienvenue"));
}
}
+61 -114
View File
@@ -1,11 +1,7 @@
use async_trait::async_trait;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use reqwest::Client;
use serde_json::json;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use super::types::{CommsChannel, QueuedComms};
@@ -21,25 +17,51 @@ pub trait CommsSender: Send + Sync {
#[derive(Debug, thiserror::Error)]
pub enum SendError {
#[error("Failed to spawn {command}: {source}")]
ProcessSpawn {
command: String,
source: std::io::Error,
},
#[error("{command} exited with non-zero status: {detail}")]
ProcessFailed { command: String, detail: String },
#[error("Channel not configured: {0:?}")]
NotConfigured(CommsChannel),
#[error("External service error: {0}")]
ExternalService(String),
#[error("Email configuration invalid: {0}")]
ConfigInvalid(String),
#[error("Invalid recipient format: {0}")]
InvalidRecipient(String),
#[error("Message construction failed: {0}")]
MessageBuild(String),
#[error("transient DNS lookup failure: {0}")]
DnsTransient(String),
#[error("permanent DNS lookup failure: {0}")]
DnsPermanent(String),
#[error("SMTP transient error: {0}")]
SmtpTransient(String),
#[error("SMTP permanent error: {0}")]
SmtpPermanent(String),
#[error("DKIM signing failed: {0}")]
DkimSign(String),
#[error("External service error: {0}")]
ExternalService(String),
#[error("Request timeout")]
Timeout,
#[error("Max retries exceeded: {0}")]
MaxRetriesExceeded(String),
}
impl SendError {
pub fn is_permanent(&self) -> bool {
match self {
Self::SmtpPermanent(_)
| Self::DnsPermanent(_)
| Self::InvalidRecipient(_)
| Self::MessageBuild(_)
| Self::DkimSign(_)
| Self::ConfigInvalid(_) => true,
Self::SmtpTransient(_)
| Self::DnsTransient(_)
| Self::Timeout
| Self::ExternalService(_)
| Self::MaxRetriesExceeded(_)
| Self::NotConfigured(_) => false,
}
}
}
fn create_http_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
@@ -100,19 +122,6 @@ where
))
}
pub fn sanitize_header_value(value: &str) -> String {
value.replace(['\r', '\n'], " ").trim().to_string()
}
pub fn mime_encode_header(value: &str) -> String {
if value.is_ascii() {
sanitize_header_value(value)
} else {
let sanitized = sanitize_header_value(value);
format!("=?UTF-8?B?{}?=", BASE64.encode(sanitized.as_bytes()))
}
}
pub fn escape_html(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
@@ -135,93 +144,6 @@ pub fn is_valid_signal_username(username: &str) -> bool {
tranquil_signal::SignalUsername::parse(username).is_ok()
}
pub struct EmailSender {
from_address: String,
from_name: String,
sendmail_path: String,
}
impl EmailSender {
pub fn new(from_address: String, from_name: String, sendmail_path: String) -> Self {
Self {
from_address,
from_name,
sendmail_path,
}
}
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> {
let from_address = cfg.email.from_address.clone()?;
let from_name = cfg.email.from_name.clone();
let sendmail_path = cfg.email.sendmail_path.clone();
Some(Self::new(from_address, from_name, sendmail_path))
}
pub fn format_email(&self, notification: &QueuedComms) -> String {
let subject = mime_encode_header(notification.subject.as_deref().unwrap_or("Notification"));
let recipient = sanitize_header_value(&notification.recipient);
let from_header = if self.from_name.is_empty() {
self.from_address.clone()
} else {
format!(
"{} <{}>",
sanitize_header_value(&self.from_name),
self.from_address
)
};
format!(
"From: {}\r\nTo: {}\r\nSubject: {}\r\nContent-Type: text/plain; charset=utf-8\r\nMIME-Version: 1.0\r\n\r\n{}",
from_header, recipient, subject, notification.body
)
}
}
#[async_trait]
impl CommsSender for EmailSender {
fn channel(&self) -> CommsChannel {
CommsChannel::Email
}
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
let email_content = self.format_email(notification);
let mut child = Command::new(&self.sendmail_path)
.arg("-t")
.arg("-oi")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| SendError::ProcessSpawn {
command: self.sendmail_path.clone(),
source: e,
})?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(email_content.as_bytes())
.await
.map_err(|e| SendError::ProcessSpawn {
command: self.sendmail_path.clone(),
source: e,
})?;
}
let output = child
.wait_with_output()
.await
.map_err(|e| SendError::ProcessSpawn {
command: self.sendmail_path.clone(),
source: e,
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SendError::ProcessFailed {
command: self.sendmail_path.clone(),
detail: stderr.to_string(),
});
}
Ok(())
}
}
const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
#[derive(Clone)]
@@ -299,7 +221,7 @@ impl DiscordSender {
"type": 1,
"options": [{
"name": "handle",
"description": "Your PDS handle (e.g. alice.example.com)",
"description": "Your PDS handle",
"type": 3,
"required": false
}]
@@ -610,3 +532,28 @@ impl CommsSender for SignalSender {
))
}
}
#[cfg(test)]
mod is_permanent_matrix {
use super::{CommsChannel, SendError};
#[test]
fn permanent_variants_are_permanent() {
assert!(SendError::SmtpPermanent("x".into()).is_permanent());
assert!(SendError::DnsPermanent("x".into()).is_permanent());
assert!(SendError::InvalidRecipient("x".into()).is_permanent());
assert!(SendError::MessageBuild("x".into()).is_permanent());
assert!(SendError::DkimSign("x".into()).is_permanent());
assert!(SendError::ConfigInvalid("x".into()).is_permanent());
}
#[test]
fn transient_variants_are_not_permanent() {
assert!(!SendError::SmtpTransient("x".into()).is_permanent());
assert!(!SendError::DnsTransient("x".into()).is_permanent());
assert!(!SendError::Timeout.is_permanent());
assert!(!SendError::ExternalService("x".into()).is_permanent());
assert!(!SendError::MaxRetriesExceeded("x".into()).is_permanent());
assert!(!SendError::NotConfigured(CommsChannel::Email).is_permanent());
}
}
+158
View File
@@ -0,0 +1,158 @@
use std::time::Duration;
use chrono::Utc;
use lettre::message::Mailbox;
use lettre::transport::smtp::AsyncSmtpTransport;
use lettre::transport::smtp::extension::ClientId;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
use tranquil_comms::email::transport::SendMode;
use tranquil_comms::email::{EmailSender, types::HeloName};
use tranquil_comms::{CommsChannel, CommsSender, CommsStatus, CommsType, QueuedComms, SendError};
use uuid::Uuid;
fn fixture(recipient: &str, subject: &str, body: &str) -> QueuedComms {
QueuedComms {
id: Uuid::new_v4(),
user_id: None,
channel: CommsChannel::Email,
comms_type: CommsType::Welcome,
status: CommsStatus::Pending,
recipient: recipient.to_string(),
subject: Some(subject.to_string()),
body: body.to_string(),
metadata: None,
attempts: 0,
max_attempts: 3,
last_error: None,
created_at: Utc::now(),
updated_at: Utc::now(),
scheduled_for: Utc::now(),
processed_at: None,
}
}
fn build_smarthost_sender(host: &str, port: u16) -> EmailSender {
build_smarthost_sender_with_total_timeout(host, port, Duration::from_secs(10))
}
fn build_smarthost_sender_with_total_timeout(
host: &str,
port: u16,
total_timeout: Duration,
) -> EmailSender {
let from: Mailbox = "Tranquil Test <noreply@nel.pet>".parse().unwrap();
let helo = HeloName::parse("mta.nel.pet").unwrap();
let transport = AsyncSmtpTransport::<lettre::Tokio1Executor>::builder_dangerous(host)
.port(port)
.hello_name(ClientId::Domain(helo.into_inner()))
.timeout(Some(Duration::from_secs(5)))
.build();
EmailSender::new(
from,
SendMode::Smarthost {
transport: Box::new(transport),
total_timeout,
},
None,
)
}
async fn drive_stub(stream: TcpStream, rcpt_response: &'static [u8]) -> std::io::Result<()> {
let (read, mut write) = stream.into_split();
let mut reader = BufReader::new(read);
write.write_all(b"220 stub ESMTP\r\n").await?;
let mut line = String::new();
loop {
line.clear();
let n = reader.read_line(&mut line).await?;
if n == 0 {
return Ok(());
}
let upper = line.to_ascii_uppercase();
let response: &[u8] = match upper.split_whitespace().next() {
Some("EHLO") | Some("HELO") => b"250-stub\r\n250 SIZE 10240000\r\n",
Some("MAIL") => b"250 OK\r\n",
Some("RCPT") => rcpt_response,
Some("DATA") => b"354 end with .\r\n",
Some("RSET") => b"250 OK\r\n",
Some("QUIT") => b"221 bye\r\n",
_ => b"500 unknown\r\n",
};
write.write_all(response).await?;
if upper.starts_with("QUIT") {
return Ok(());
}
}
}
async fn spawn_stub(rcpt_response: &'static [u8]) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let _ = drive_stub(stream, rcpt_response).await;
});
port
}
#[tokio::test]
async fn rcpt_550_classifies_as_smtp_permanent() {
let port = spawn_stub(b"550 5.1.1 user unknown\r\n").await;
let sender = build_smarthost_sender("127.0.0.1", port);
let result = sender.send(&fixture("nel@nel.pet", "x", "x")).await;
match result {
Err(SendError::SmtpPermanent(_)) => {}
other => panic!("expected SmtpPermanent, got {other:?}"),
}
}
#[tokio::test]
async fn rcpt_421_classifies_as_smtp_transient() {
let port = spawn_stub(b"421 4.7.0 try again later\r\n").await;
let sender = build_smarthost_sender("127.0.0.1", port);
let result = sender.send(&fixture("nel@nel.pet", "x", "x")).await;
match result {
Err(SendError::SmtpTransient(_)) => {}
other => panic!("expected SmtpTransient, got {other:?}"),
}
}
#[tokio::test]
async fn invalid_recipient_classifies_as_invalid_recipient() {
let port = spawn_stub(b"250 OK\r\n").await;
let sender = build_smarthost_sender("127.0.0.1", port);
let result = sender.send(&fixture("not-an-address", "x", "x")).await;
match result {
Err(SendError::InvalidRecipient(_)) => {}
other => panic!("expected InvalidRecipient, got {other:?}"),
}
}
async fn spawn_silent_stub() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (_stream, _) = listener.accept().await.unwrap();
std::future::pending::<()>().await;
});
port
}
#[tokio::test]
async fn smarthost_silent_relay_hits_total_timeout() {
let port = spawn_silent_stub().await;
let sender =
build_smarthost_sender_with_total_timeout("127.0.0.1", port, Duration::from_millis(500));
let start = std::time::Instant::now();
let result = sender.send(&fixture("nel@nel.pet", "x", "x")).await;
let elapsed = start.elapsed();
match result {
Err(SendError::Timeout) => {}
other => panic!("expected Timeout, got {other:?}"),
}
assert!(
elapsed < Duration::from_secs(2),
"send returned in {elapsed:?}, expected close to 500ms total_timeout"
);
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -7,7 +7,7 @@ use uuid::Uuid;
use crate::DbError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BacklinkPath {
Subject,
SubjectUri,
+2
View File
@@ -244,6 +244,8 @@ pub trait InfraRepository: Send + Sync {
async fn mark_comms_failed(&self, id: Uuid, error: &str) -> Result<(), DbError>;
async fn mark_comms_failed_permanent(&self, id: Uuid, error: &str) -> Result<(), DbError>;
async fn create_invite_code(
&self,
code: &str,
+17 -2
View File
@@ -44,6 +44,7 @@ pub struct UserRow {
pub deactivated_at: Option<DateTime<Utc>>,
pub takedown_ref: Option<String>,
pub is_admin: bool,
pub inbound_migration: bool,
}
#[derive(Debug, Clone)]
@@ -144,12 +145,12 @@ pub trait UserRepository: Send + Sync {
async fn get_by_email(&self, email: &str) -> Result<Option<UserForVerification>, DbError>;
async fn get_login_check_by_handle_or_email(
async fn get_login_check_by_identifier(
&self,
identifier: &str,
) -> Result<Option<UserLoginCheck>, DbError>;
async fn get_login_info_by_handle_or_email(
async fn get_login_info_by_identifier(
&self,
identifier: &str,
) -> Result<Option<UserLoginInfo>, DbError>;
@@ -358,6 +359,19 @@ pub trait UserRepository: Send + Sync {
challenge_type: WebauthnChallengeType,
) -> Result<(), DbError>;
async fn save_discoverable_challenge(
&self,
request_key: &str,
state_json: &str,
) -> Result<Uuid, DbError>;
async fn load_discoverable_challenge(
&self,
request_key: &str,
) -> Result<Option<String>, DbError>;
async fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), DbError>;
async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError>;
async fn get_totp_record_state(&self, did: &Did) -> Result<Option<TotpRecordState>, DbError>;
@@ -982,6 +996,7 @@ pub struct CreatePasswordAccountInput {
pub telegram_username: Option<String>,
pub signal_username: Option<String>,
pub deactivated_at: Option<DateTime<Utc>>,
pub inbound_migration: bool,
pub encrypted_key_bytes: Vec<u8>,
pub encryption_version: i32,
pub reserved_key_id: Option<Uuid>,
+24 -2
View File
@@ -65,9 +65,13 @@ impl InfraRepository for PostgresInfraRepository {
SET status = 'processing', updated_at = NOW()
WHERE id IN (
SELECT id FROM comms_queue
WHERE status = 'pending'
WHERE attempts < max_attempts
AND scheduled_for <= $1
AND attempts < max_attempts
AND (
status = 'pending'
OR (status = 'processing'
AND updated_at < $1 - INTERVAL '10 minutes')
)
ORDER BY scheduled_for ASC
LIMIT $2
FOR UPDATE SKIP LOCKED
@@ -127,6 +131,24 @@ impl InfraRepository for PostgresInfraRepository {
Ok(())
}
async fn mark_comms_failed_permanent(&self, id: Uuid, error: &str) -> Result<(), DbError> {
sqlx::query!(
r#"UPDATE comms_queue
SET status = 'failed'::comms_status,
attempts = max_attempts,
last_error = $2,
updated_at = NOW()
WHERE id = $1"#,
id,
error
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn create_invite_code(
&self,
code: &str,
+1 -1
View File
@@ -374,7 +374,7 @@ impl OAuthRepository for PostgresOAuthRepository {
WHERE id IN (
SELECT id FROM oauth_token
WHERE did = $1
ORDER BY updated_at ASC
ORDER BY created_at DESC
OFFSET $2
)
"#,
+66 -10
View File
@@ -47,7 +47,7 @@ pub(crate) fn map_sqlx_error(e: sqlx::Error) -> DbError {
impl UserRepository for PostgresUserRepository {
async fn get_by_did(&self, did: &Did) -> Result<Option<UserRow>, DbError> {
let row = sqlx::query!(
r#"SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin
r#"SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration
FROM users WHERE did = $1"#,
did.as_str()
)
@@ -64,12 +64,13 @@ impl UserRepository for PostgresUserRepository {
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
is_admin: r.is_admin,
inbound_migration: r.inbound_migration,
}))
}
async fn get_by_handle(&self, handle: &Handle) -> Result<Option<UserRow>, DbError> {
let row = sqlx::query!(
r#"SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin
r#"SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration
FROM users WHERE handle = $1"#,
handle.as_str()
)
@@ -86,6 +87,7 @@ impl UserRepository for PostgresUserRepository {
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
is_admin: r.is_admin,
inbound_migration: r.inbound_migration,
}))
}
@@ -1102,6 +1104,59 @@ impl UserRepository for PostgresUserRepository {
Ok(())
}
async fn save_discoverable_challenge(
&self,
request_key: &str,
state_json: &str,
) -> Result<Uuid, DbError> {
let id = Uuid::new_v4();
let challenge = id.as_bytes().to_vec();
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(5);
sqlx::query!(
r#"INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)
VALUES ($1, $2, $3, 'discoverable', $4, $5)"#,
id,
request_key,
challenge,
state_json,
expires_at,
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(id)
}
async fn load_discoverable_challenge(
&self,
request_key: &str,
) -> Result<Option<String>, DbError> {
let row = sqlx::query_scalar!(
r#"SELECT state_json FROM webauthn_challenges
WHERE did = $1 AND challenge_type = 'discoverable' AND expires_at > NOW()
ORDER BY created_at DESC LIMIT 1"#,
request_key,
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row)
}
async fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), DbError> {
sqlx::query!(
"DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'discoverable'",
request_key,
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError> {
let row = sqlx::query!(
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
@@ -1330,12 +1385,12 @@ impl UserRepository for PostgresUserRepository {
Ok(())
}
async fn get_login_check_by_handle_or_email(
async fn get_login_check_by_identifier(
&self,
identifier: &str,
) -> Result<Option<UserLoginCheck>, DbError> {
sqlx::query!(
"SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1",
"SELECT did, password_hash FROM users WHERE handle = $1 OR did = $1",
identifier
)
.fetch_optional(&self.pool)
@@ -1349,7 +1404,7 @@ impl UserRepository for PostgresUserRepository {
})
}
async fn get_login_info_by_handle_or_email(
async fn get_login_info_by_identifier(
&self,
identifier: &str,
) -> Result<Option<UserLoginInfo>, DbError> {
@@ -1361,7 +1416,7 @@ impl UserRepository for PostgresUserRepository {
email_verified, discord_verified, telegram_verified, signal_verified,
account_type as "account_type!: AccountType"
FROM users
WHERE handle = $1 OR email = $1
WHERE handle = $1 OR did = $1
"#,
identifier
)
@@ -1524,7 +1579,7 @@ impl UserRepository for PostgresUserRepository {
COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as "email_2fa_enabled!"
FROM users u
JOIN user_keys k ON u.id = k.user_id
WHERE u.handle = $1 OR u.email = $1 OR u.did = $1"#,
WHERE u.handle = $1 OR u.did = $1"#,
identifier
)
.fetch_optional(&self.pool)
@@ -1810,7 +1865,7 @@ impl UserRepository for PostgresUserRepository {
async fn activate_account(&self, did: &Did) -> Result<bool, DbError> {
let result = sqlx::query!(
"UPDATE users SET deactivated_at = NULL WHERE did = $1",
"UPDATE users SET deactivated_at = NULL, inbound_migration = FALSE WHERE did = $1",
did.as_str()
)
.execute(&self.pool)
@@ -2373,8 +2428,8 @@ impl UserRepository for PostgresUserRepository {
handle, email, did, password_hash,
preferred_comms_channel,
discord_username, telegram_username, signal_username,
is_admin, deactivated_at, email_verified
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, FALSE) RETURNING id"#,
is_admin, deactivated_at, inbound_migration, email_verified
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, FALSE) RETURNING id"#,
)
.bind(input.handle.as_str())
.bind(&input.email)
@@ -2386,6 +2441,7 @@ impl UserRepository for PostgresUserRepository {
.bind(&input.signal_username)
.bind(is_first_user)
.bind(input.deactivated_at)
.bind(input.inbound_migration)
.fetch_one(&mut *tx)
.await;
+1
View File
@@ -24,3 +24,4 @@ urlencoding = { workspace = true, optional = true }
[dev-dependencies]
wiremock = { workspace = true }
tokio = { workspace = true }
futures = { workspace = true }
+422 -31
View File
@@ -5,46 +5,102 @@ use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tokio::sync::Notify;
const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const POSITIVE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const REFRESH_FAILURE_BACKOFF: Duration = Duration::from_secs(60);
const MAX_DYNAMIC_SCHEMAS: usize = 1024;
struct NegativeEntry {
expires_at: Instant,
}
struct PositiveEntry {
doc: Arc<LexiconDoc>,
expires_at: Instant,
}
pub(crate) enum CacheEntry {
Fresh(Arc<LexiconDoc>),
Stale(Arc<LexiconDoc>),
}
impl CacheEntry {
#[cfg(test)]
fn is_fresh(&self) -> bool {
matches!(self, Self::Fresh(_))
}
}
struct SchemaStore {
schemas: HashMap<String, Arc<LexiconDoc>>,
schemas: HashMap<String, PositiveEntry>,
insertion_order: VecDeque<String>,
}
pub struct DynamicRegistry {
store: RwLock<SchemaStore>,
negative_cache: RwLock<HashMap<String, NegativeEntry>>,
in_flight: RwLock<HashMap<String, Arc<Notify>>>,
network_disabled: AtomicBool,
}
struct InFlightGuard<'a> {
registry: &'a DynamicRegistry,
nsid: String,
}
impl Drop for InFlightGuard<'_> {
fn drop(&mut self) {
let notify = self.registry.in_flight.write().remove(&self.nsid);
if let Some(n) = notify {
n.notify_waiters();
}
}
}
impl DynamicRegistry {
pub fn new() -> Self {
let network_disabled =
std::env::var("TRANQUIL_LEXICON_OFFLINE").is_ok_and(|v| v == "1" || v == "true");
Self {
store: RwLock::new(SchemaStore {
schemas: HashMap::new(),
insertion_order: VecDeque::new(),
}),
negative_cache: RwLock::new(HashMap::new()),
network_disabled: AtomicBool::new(network_disabled),
in_flight: RwLock::new(HashMap::new()),
network_disabled: AtomicBool::new(false),
}
}
#[allow(dead_code)]
pub fn from_env() -> Self {
let registry = Self::new();
let disabled =
std::env::var("TRANQUIL_LEXICON_OFFLINE").is_ok_and(|v| v == "1" || v == "true");
registry.set_network_disabled(disabled);
registry
}
pub fn set_network_disabled(&self, disabled: bool) {
self.network_disabled.store(disabled, Ordering::Relaxed);
}
pub fn get(&self, nsid: &str) -> Option<Arc<LexiconDoc>> {
self.store.read().schemas.get(nsid).cloned()
pub fn get_cached(&self, nsid: &str) -> Option<Arc<LexiconDoc>> {
self.store
.read()
.schemas
.get(nsid)
.map(|e| Arc::clone(&e.doc))
}
pub(crate) fn get_entry(&self, nsid: &str) -> Option<CacheEntry> {
let now = Instant::now();
self.store.read().schemas.get(nsid).map(|e| {
if e.expires_at > now {
CacheEntry::Fresh(Arc::clone(&e.doc))
} else {
CacheEntry::Stale(Arc::clone(&e.doc))
}
})
}
pub fn is_negative_cached(&self, nsid: &str) -> bool {
@@ -56,7 +112,7 @@ impl DynamicRegistry {
fn insert_negative(&self, nsid: &str) {
let mut cache = self.negative_cache.write();
if cache.len() > MAX_DYNAMIC_SCHEMAS {
if cache.len() >= MAX_DYNAMIC_SCHEMAS {
let now = Instant::now();
cache.retain(|_, entry| entry.expires_at > now);
}
@@ -87,29 +143,95 @@ impl DynamicRegistry {
});
}
if store
.schemas
.insert(nsid.clone(), Arc::clone(&arc))
.is_some()
{
let entry = PositiveEntry {
doc: Arc::clone(&arc),
expires_at: Instant::now() + POSITIVE_CACHE_TTL,
};
if store.schemas.insert(nsid.clone(), entry).is_some() {
store.insertion_order.retain(|k| k != &nsid);
}
store.insertion_order.push_back(nsid.clone());
drop(store);
self.negative_cache.write().remove(&arc.id);
arc
}
fn bump_expiry(&self, nsid: &str, duration: Duration) {
let mut store = self.store.write();
if let Some(entry) = store.schemas.get_mut(nsid) {
entry.expires_at = Instant::now() + duration;
}
}
pub async fn resolve_and_cache(&self, nsid: &str) -> Result<Arc<LexiconDoc>, ResolveError> {
if let Some(doc) = self.get(nsid) {
return Ok(doc);
self.resolve_and_cache_with(nsid, |n| async move { resolve_lexicon(&n).await })
.await
}
async fn resolve_and_cache_with<F, Fut>(
&self,
nsid: &str,
resolver: F,
) -> Result<Arc<LexiconDoc>, ResolveError>
where
F: FnOnce(String) -> Fut,
Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>,
{
match self.get_entry(nsid) {
Some(CacheEntry::Fresh(doc)) => Ok(doc),
Some(CacheEntry::Stale(stale)) => self.refresh_stale(nsid, stale, resolver).await,
None => self.resolve_fresh(nsid, resolver).await,
}
}
async fn refresh_stale<F, Fut>(
&self,
nsid: &str,
stale: Arc<LexiconDoc>,
resolver: F,
) -> Result<Arc<LexiconDoc>, ResolveError>
where
F: FnOnce(String) -> Fut,
Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>,
{
if self.network_disabled.load(Ordering::Relaxed) {
return Ok(stale);
}
match self.acquire_leadership(nsid) {
Some(_guard) => match resolver(nsid.to_string()).await {
Ok(doc) => Ok(self.insert_schema(doc)),
Err(e) => {
self.bump_expiry(nsid, REFRESH_FAILURE_BACKOFF);
tracing::warn!(
nsid = nsid,
error = %e,
"lexicon refresh failed, serving stale cached entry"
);
Ok(stale)
}
},
None => {
self.wait_for_leader(nsid).await;
Ok(self.get_cached(nsid).unwrap_or(stale))
}
}
}
async fn resolve_fresh<F, Fut>(
&self,
nsid: &str,
resolver: F,
) -> Result<Arc<LexiconDoc>, ResolveError>
where
F: FnOnce(String) -> Fut,
Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>,
{
if self.network_disabled.load(Ordering::Relaxed) {
return Err(ResolveError::NetworkDisabled);
}
if self.is_negative_cached(nsid) {
return Err(ResolveError::NegativelyCached {
nsid: nsid.to_string(),
@@ -117,19 +239,73 @@ impl DynamicRegistry {
});
}
match resolve_lexicon(nsid).await {
Ok(doc) => Ok(self.insert_schema(doc)),
Err(e) => {
tracing::debug!(nsid = nsid, error = %e, "caching negative resolution result");
self.insert_negative(nsid);
Err(e)
match self.acquire_leadership(nsid) {
Some(_guard) => match resolver(nsid.to_string()).await {
Ok(doc) => Ok(self.insert_schema(doc)),
Err(e) => {
self.insert_negative(nsid);
tracing::debug!(nsid = nsid, error = %e, "caching negative resolution result");
Err(e)
}
},
None => {
self.wait_for_leader(nsid).await;
match self.get_cached(nsid) {
Some(doc) => Ok(doc),
None if self.is_negative_cached(nsid) => Err(ResolveError::NegativelyCached {
nsid: nsid.to_string(),
ttl_secs: NEGATIVE_CACHE_TTL.as_secs(),
}),
None => Err(ResolveError::LeaderAborted {
nsid: nsid.to_string(),
}),
}
}
}
}
fn acquire_leadership(&self, nsid: &str) -> Option<InFlightGuard<'_>> {
let mut map = self.in_flight.write();
if map.contains_key(nsid) {
None
} else {
map.insert(nsid.to_string(), Arc::new(Notify::new()));
Some(InFlightGuard {
registry: self,
nsid: nsid.to_string(),
})
}
}
async fn wait_for_leader(&self, nsid: &str) {
let notify = {
let map = self.in_flight.read();
match map.get(nsid) {
Some(n) => Arc::clone(n),
None => return,
}
};
let notified = notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
let still_active = self.in_flight.read().contains_key(nsid);
if !still_active {
return;
}
notified.as_mut().await;
}
pub fn schema_count(&self) -> usize {
self.store.read().schemas.len()
}
#[cfg(test)]
fn expire_now(&self, nsid: &str) {
let mut store = self.store.write();
if let Some(entry) = store.schemas.get_mut(nsid) {
entry.expires_at = Instant::now();
}
}
}
impl Default for DynamicRegistry {
@@ -171,7 +347,7 @@ mod tests {
#[test]
fn test_empty_lookup() {
let registry = DynamicRegistry::new();
assert!(registry.get("com.example.nonexistent").is_none());
assert!(registry.get_cached("com.example.nonexistent").is_none());
assert_eq!(registry.schema_count(), 0);
}
@@ -188,9 +364,12 @@ mod tests {
assert_eq!(arc.id, "com.example.test");
assert_eq!(registry.schema_count(), 1);
let retrieved = registry.get("com.example.test");
let retrieved = registry.get_cached("com.example.test");
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().id, "com.example.test");
let entry = registry.get_entry("com.example.test").unwrap();
assert!(entry.is_fresh(), "freshly inserted entry must be fresh");
}
#[test]
@@ -210,6 +389,218 @@ mod tests {
assert!(!registry.is_negative_cached("com.example.test"));
}
#[test]
fn test_positive_entry_reports_stale_after_ttl() {
let registry = DynamicRegistry::new();
let doc = LexiconDoc {
lexicon: 1,
id: "pet.nel.stale".to_string(),
defs: HashMap::new(),
};
registry.insert_schema(doc);
assert!(registry.get_entry("pet.nel.stale").unwrap().is_fresh());
registry.expire_now("pet.nel.stale");
assert!(
!registry.get_entry("pet.nel.stale").unwrap().is_fresh(),
"entry past expiry must be reported stale"
);
}
#[tokio::test]
async fn test_stale_served_on_resolve_failure() {
let registry = DynamicRegistry::new();
let doc = LexiconDoc {
lexicon: 1,
id: "pet.nel.flaky".to_string(),
defs: HashMap::new(),
};
registry.insert_schema(doc);
registry.expire_now("pet.nel.flaky");
let result = registry
.resolve_and_cache_with("pet.nel.flaky", |n| async move {
Err::<LexiconDoc, _>(ResolveError::DnsLookup {
domain: n,
reason: "simulated failure".to_string(),
})
})
.await;
let served = result.expect("stale entry must be served when refresh fails");
assert_eq!(served.id, "pet.nel.flaky");
assert!(
registry.get_entry("pet.nel.flaky").unwrap().is_fresh(),
"failed refresh must bump expiry so subsequent lookups skip the resolver"
);
assert!(
!registry.is_negative_cached("pet.nel.flaky"),
"stale refresh failure must not poison negative cache"
);
}
#[tokio::test]
async fn test_fresh_hit_skips_resolver() {
let registry = DynamicRegistry::new();
let doc = LexiconDoc {
lexicon: 1,
id: "pet.nel.fresh".to_string(),
defs: HashMap::new(),
};
registry.insert_schema(doc);
let result = registry
.resolve_and_cache_with("pet.nel.fresh", |_| async move {
panic!("resolver must not run on fresh hit")
})
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_stale_served_when_network_disabled() {
let registry = DynamicRegistry::new();
let doc = LexiconDoc {
lexicon: 1,
id: "pet.nel.offline".to_string(),
defs: HashMap::new(),
};
registry.insert_schema(doc);
registry.expire_now("pet.nel.offline");
registry.set_network_disabled(true);
let result = registry
.resolve_and_cache_with("pet.nel.offline", |_| async move {
panic!("resolver must not run when network disabled")
})
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_successful_refresh_updates_cached_at() {
let registry = DynamicRegistry::new();
let doc = LexiconDoc {
lexicon: 1,
id: "pet.nel.refresh".to_string(),
defs: HashMap::new(),
};
registry.insert_schema(doc);
registry.expire_now("pet.nel.refresh");
assert!(!registry.get_entry("pet.nel.refresh").unwrap().is_fresh());
let refreshed = registry
.resolve_and_cache_with("pet.nel.refresh", |n| async move {
Ok(LexiconDoc {
lexicon: 1,
id: n,
defs: HashMap::new(),
})
})
.await
.unwrap();
assert_eq!(refreshed.id, "pet.nel.refresh");
assert!(
registry.get_entry("pet.nel.refresh").unwrap().is_fresh(),
"refresh must restore freshness"
);
}
#[tokio::test]
async fn test_single_flight_dedups_concurrent_resolves() {
use std::sync::atomic::AtomicUsize;
let registry = Arc::new(DynamicRegistry::new());
let calls = Arc::new(AtomicUsize::new(0));
let tasks: Vec<_> = (0..16)
.map(|_| {
let registry = Arc::clone(&registry);
let calls = Arc::clone(&calls);
tokio::spawn(async move {
registry
.resolve_and_cache_with("pet.nel.herd", |n| {
let calls = Arc::clone(&calls);
async move {
calls.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(50)).await;
Ok(LexiconDoc {
lexicon: 1,
id: n,
defs: HashMap::new(),
})
}
})
.await
})
})
.collect();
let results = futures_collect(tasks).await;
results
.iter()
.for_each(|r| assert!(r.is_ok(), "all single-flight callers must succeed"));
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"single-flight must coalesce concurrent resolves"
);
assert_eq!(registry.schema_count(), 1);
}
#[tokio::test]
async fn test_single_flight_followers_observe_leader_failure() {
use std::sync::atomic::AtomicUsize;
let registry = Arc::new(DynamicRegistry::new());
let calls = Arc::new(AtomicUsize::new(0));
let tasks: Vec<_> = (0..8)
.map(|_| {
let registry = Arc::clone(&registry);
let calls = Arc::clone(&calls);
tokio::spawn(async move {
registry
.resolve_and_cache_with("pet.nel.failHerd", |n| {
let calls = Arc::clone(&calls);
async move {
calls.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(50)).await;
Err::<LexiconDoc, _>(ResolveError::DnsLookup {
domain: n,
reason: "simulated".to_string(),
})
}
})
.await
})
})
.collect();
let results = futures_collect(tasks).await;
results
.iter()
.for_each(|r| assert!(r.is_err(), "all followers must observe leader failure"));
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"single-flight must coalesce failing resolves too"
);
assert!(registry.is_negative_cached("pet.nel.failHerd"));
}
async fn futures_collect<T>(handles: Vec<tokio::task::JoinHandle<T>>) -> Vec<T> {
futures::future::join_all(handles)
.await
.into_iter()
.map(|r| r.expect("task panicked"))
.collect()
}
#[test]
fn test_eviction_is_fifo() {
let registry = DynamicRegistry::new();
@@ -217,7 +608,7 @@ mod tests {
(0..MAX_DYNAMIC_SCHEMAS).for_each(|i| {
let doc = LexiconDoc {
lexicon: 1,
id: format!("com.example.schema{}", i),
id: format!("pet.nel.schema{}", i),
defs: HashMap::new(),
};
registry.insert_schema(doc);
@@ -226,23 +617,23 @@ mod tests {
let trigger = LexiconDoc {
lexicon: 1,
id: "com.example.trigger".to_string(),
id: "pet.nel.trigger".to_string(),
defs: HashMap::new(),
};
registry.insert_schema(trigger);
assert!(
registry.get("com.example.schema0").is_none(),
registry.get_cached("pet.nel.schema0").is_none(),
"oldest entry should be evicted"
);
assert!(
registry.get("com.example.trigger").is_some(),
registry.get_cached("pet.nel.trigger").is_some(),
"newly inserted entry should exist"
);
let evict_count = MAX_DYNAMIC_SCHEMAS / 4;
assert!(
registry
.get(&format!("com.example.schema{}", evict_count))
.get_cached(&format!("pet.nel.schema{}", evict_count))
.is_some(),
"entry after eviction window should survive"
);
@@ -253,7 +644,7 @@ mod tests {
let registry = DynamicRegistry::new();
let doc = LexiconDoc {
lexicon: 1,
id: "com.example.tracked".to_string(),
id: "pet.nel.tracked".to_string(),
defs: HashMap::new(),
};
let arc = registry.insert_schema(doc);
@@ -265,7 +656,7 @@ mod tests {
(0..MAX_DYNAMIC_SCHEMAS).for_each(|i| {
registry.insert_schema(LexiconDoc {
lexicon: 1,
id: format!("com.example.filler{}", i),
id: format!("pet.nel.filler{}", i),
defs: HashMap::new(),
});
});
+2 -2
View File
@@ -25,7 +25,7 @@ impl LexiconRegistry {
Self {
schemas: HashMap::new(),
#[cfg(feature = "resolve")]
dynamic: crate::dynamic::DynamicRegistry::new(),
dynamic: crate::dynamic::DynamicRegistry::from_env(),
}
}
@@ -43,7 +43,7 @@ impl LexiconRegistry {
self.schemas.get(nsid).cloned().or_else(|| {
#[cfg(feature = "resolve")]
{
self.dynamic.get(nsid)
self.dynamic.get_cached(nsid)
}
#[cfg(not(feature = "resolve"))]
{
+2
View File
@@ -70,6 +70,8 @@ pub enum ResolveError {
NegativelyCached { nsid: String, ttl_secs: u64 },
#[error("network resolution disabled")]
NetworkDisabled,
#[error("leader task for {nsid} aborted before completion")]
LeaderAborted { nsid: String },
}
pub fn nsid_to_authority(nsid: &str) -> Result<String, ResolveError> {
+3 -1
View File
@@ -322,7 +322,9 @@ fn validate_blob_ref(
if let Some(ref accept) = lex_blob.accept {
let mime_type = obj.get("mimeType").and_then(|v| v.as_str()).unwrap_or("");
let matched = accept.iter().any(|pattern| mime_type_matches_accept_pattern(mime_type, pattern));
let matched = accept
.iter()
.any(|pattern| mime_type_matches_accept_pattern(mime_type, pattern));
if !mime_type.is_empty() && !matched {
return Err(LexValidationError::field(
path,
@@ -108,7 +108,7 @@ pub async fn authorize_get(
match state
.repos
.user
.get_login_check_by_handle_or_email(normalized.as_str())
.get_login_check_by_identifier(normalized.as_str())
.await
{
Ok(Some(user)) => {
@@ -310,6 +310,7 @@ pub async fn authorize_post(
State(state): State<AppState>,
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
headers: HeaderMap,
client_ip: ClientIp,
Json(form): Json<AuthorizeSubmit>,
) -> Response {
let json_response = wants_json(&headers);
@@ -401,7 +402,7 @@ pub async fn authorize_post(
let user = match state
.repos
.user
.get_login_info_by_handle_or_email(normalized_username.as_str())
.get_login_info_by_identifier(normalized_username.as_str())
.await
{
Ok(Some(u)) => u,
@@ -410,7 +411,7 @@ pub async fn authorize_post(
&form.password,
"$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK",
);
return show_login_error("Invalid handle/email or password.", json_response);
return show_login_error("Invalid identifier or password.", json_response);
}
Err(_) => return show_login_error("An error occurred. Please try again.", json_response),
};
@@ -486,10 +487,9 @@ pub async fn authorize_post(
None => false,
};
if !password_valid {
return show_login_error("Invalid handle/email or password.", json_response);
return show_login_error("Invalid identifier or password.", json_response);
}
let is_verified = user.channel_verification.has_any_verified();
if !is_verified {
if tranquil_api::server::verification_blocks_login(&user.channel_verification) {
let resend_info = tranquil_api::server::auto_resend_verification(&state, &user.did).await;
let handle = resend_info
.as_ref()
@@ -617,7 +617,7 @@ pub async fn authorize_post(
let device_data = DeviceData {
session_id: SessionId::generate(),
user_agent: extract_user_agent(&headers),
ip_address: extract_client_ip(&headers, None),
ip_address: client_ip.into_string(),
last_seen_at: Utc::now(),
};
if state
@@ -854,8 +854,7 @@ pub async fn authorize_select(
);
}
};
let is_verified = user.channel_verification.has_any_verified();
if !is_verified {
if tranquil_api::server::verification_blocks_login(&user.channel_verification) {
let resend_info = tranquil_api::server::auto_resend_verification(&state, &did).await;
return (
StatusCode::FORBIDDEN,
@@ -23,7 +23,7 @@ use tranquil_pds::rate_limit::{
};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{Did, Handle, PlainPassword};
use tranquil_pds::util::extract_client_ip;
use tranquil_pds::util::ClientIp;
use tranquil_types::{AuthorizationCode, ClientId, DeviceId as DeviceIdType, RequestId};
use urlencoding::encode as url_encode;
@@ -22,7 +22,7 @@ pub async fn check_user_has_passkeys(
let user = state
.repos
.user
.get_login_check_by_handle_or_email(bare_identifier.as_str())
.get_login_check_by_identifier(bare_identifier.as_str())
.await;
let has_passkeys = match user {
@@ -55,7 +55,7 @@ pub async fn check_user_security_status(
let user = state
.repos
.user
.get_login_check_by_handle_or_email(normalized_identifier.as_str())
.get_login_check_by_identifier(normalized_identifier.as_str())
.await;
let (has_passkeys, has_totp, has_password, is_delegated, did): (
@@ -99,7 +99,7 @@ pub async fn check_user_security_status(
#[derive(Debug, Deserialize)]
pub struct PasskeyStartInput {
pub request_uri: String,
pub identifier: String,
pub identifier: Option<String>,
pub delegated_did: Option<String>,
}
@@ -160,14 +160,88 @@ pub async fn passkey_start(
.into_response();
}
match form.identifier.filter(|s| !s.trim().is_empty()) {
Some(identifier) => {
passkey_start_named(
state,
identifier,
form.delegated_did,
request_data,
passkey_start_request_id,
)
.await
}
None => passkey_start_discoverable(state, passkey_start_request_id).await,
}
}
async fn passkey_start_discoverable(state: AppState, request_id: RequestId) -> Response {
let (rcr, auth_state) = match state.webauthn_config.start_discoverable_authentication() {
Ok(result) => result,
Err(e) => {
tracing::error!(error = %e, "Failed to start discoverable passkey authentication");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "Failed to start authentication."
})),
)
.into_response();
}
};
let state_json = match serde_json::to_string(&auth_state) {
Ok(j) => j,
Err(e) => {
tracing::error!(error = %e, "Failed to serialize authentication state");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
};
if let Err(e) = state
.repos
.user
.save_discoverable_challenge(request_id.as_str(), &state_json)
.await
{
tracing::error!(error = %e, "Failed to save discoverable authentication state");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({}));
Json(PasskeyStartResponse { options }).into_response()
}
async fn passkey_start_named(
state: AppState,
identifier: String,
delegated_did: Option<String>,
request_data: tranquil_pds::oauth::RequestData,
passkey_start_request_id: RequestId,
) -> Response {
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized_username =
NormalizedLoginIdentifier::normalize(&form.identifier, hostname_for_handles);
NormalizedLoginIdentifier::normalize(&identifier, hostname_for_handles);
let user = match state
.repos
.user
.get_login_info_by_handle_or_email(normalized_username.as_str())
.get_login_info_by_identifier(normalized_username.as_str())
.await
{
Ok(Some(u)) => u,
@@ -215,9 +289,7 @@ pub async fn passkey_start(
.into_response();
}
let is_verified = user.channel_verification.has_any_verified();
if !is_verified {
if tranquil_api::server::verification_blocks_login(&user.channel_verification) {
let resend_info = tranquil_api::server::auto_resend_verification(&state, &user.did).await;
return (
StatusCode::FORBIDDEN,
@@ -325,7 +397,7 @@ pub async fn passkey_start(
.into_response();
}
let delegation_from_param = match &form.delegated_did {
let delegation_from_param = match &delegated_did {
Some(delegated_did_str) => match delegated_did_str.parse::<tranquil_types::Did>() {
Ok(delegated_did) if delegated_did != user.did => {
match state
@@ -471,85 +543,6 @@ pub async fn passkey_finish(
.into_response();
}
let did_str = match request_data.did {
Some(d) => d,
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "No passkey authentication in progress."
})),
)
.into_response();
}
};
let did: tranquil_types::Did = match did_str.parse() {
Ok(d) => d,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Invalid DID format."
})),
)
.into_response();
}
};
let controller_did: Option<tranquil_types::Did> = request_data
.controller_did
.as_ref()
.and_then(|s| s.parse().ok());
let passkey_owner_did = controller_did.as_ref().unwrap_or(&did);
let auth_state_json = match state
.repos
.user
.load_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication)
.await
{
Ok(Some(s)) => s,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "No passkey authentication in progress or challenge expired."
})),
)
.into_response();
}
Err(e) => {
tracing::error!(error = %e, "Failed to load authentication state");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
};
let auth_state: webauthn_rs::prelude::SecurityKeyAuthentication =
match serde_json::from_str(&auth_state_json) {
Ok(s) => s,
Err(e) => {
tracing::error!(error = %e, "Failed to deserialize authentication state");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
};
let credential: webauthn_rs::prelude::PublicKeyCredential =
match serde_json::from_value(form.credential) {
Ok(c) => c,
@@ -566,33 +559,32 @@ pub async fn passkey_finish(
}
};
let auth_result = match state
.webauthn_config
.finish_authentication(&credential, &auth_state)
{
Ok(r) => r,
Err(e) => {
tracing::warn!(error = %e, did = %did, "Failed to verify passkey authentication");
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Passkey verification failed."
})),
)
.into_response();
let (did, auth_result) = match request_data.did.clone() {
Some(did) => match passkey_finish_named(&state, did, &request_data, &credential).await {
Ok(result) => result,
Err(response) => return response,
},
None => {
let result =
match passkey_finish_discoverable(&state, &credential, &passkey_finish_request_id)
.await
{
Ok(result) => result,
Err(response) => return response,
};
if state
.repos
.oauth
.set_authorization_did(&passkey_finish_request_id, &result.0, None)
.await
.is_err()
{
return OAuthError::ServerError("An error occurred.".into()).into_response();
}
result
}
};
if let Err(e) = state
.repos
.user
.delete_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication)
.await
{
tracing::warn!(error = %e, "Failed to delete authentication state");
}
if auth_result.needs_update() {
let cred_id_bytes = auth_result.cred_id().as_slice();
match state
@@ -691,6 +683,187 @@ pub async fn passkey_finish(
.into_response()
}
async fn passkey_finish_named(
state: &AppState,
did: tranquil_types::Did,
request_data: &tranquil_pds::oauth::RequestData,
credential: &webauthn_rs::prelude::PublicKeyCredential,
) -> Result<
(
tranquil_types::Did,
webauthn_rs::prelude::AuthenticationResult,
),
Response,
> {
let passkey_owner_did = request_data.controller_did.as_ref().unwrap_or(&did);
let auth_state_json = state
.repos
.user
.load_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to load authentication state");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
).into_response()
})?
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "No passkey authentication in progress or challenge expired."
})),
).into_response()
})?;
let auth_state: webauthn_rs::prelude::SecurityKeyAuthentication =
serde_json::from_str(&auth_state_json).map_err(|e| {
tracing::error!(error = %e, "Failed to deserialize authentication state");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
).into_response()
})?;
let auth_result = state
.webauthn_config
.finish_authentication(credential, &auth_state)
.map_err(|e| {
tracing::warn!(error = %e, did = %did, "Failed to verify passkey authentication");
(
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Passkey verification failed."
})),
)
.into_response()
})?;
let _ = state
.repos
.user
.delete_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication)
.await;
Ok((did, auth_result))
}
async fn passkey_finish_discoverable(
state: &AppState,
credential: &webauthn_rs::prelude::PublicKeyCredential,
request_id: &RequestId,
) -> Result<
(
tranquil_types::Did,
webauthn_rs::prelude::AuthenticationResult,
),
Response,
> {
let auth_state_json = state
.repos
.user
.load_discoverable_challenge(request_id.as_str())
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to load discoverable authentication state");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
).into_response()
})?
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "No passkey authentication in progress or challenge expired."
})),
).into_response()
})?;
let auth_state: webauthn_rs::prelude::DiscoverableAuthentication =
serde_json::from_str(&auth_state_json).map_err(|e| {
tracing::error!(error = %e, "Failed to deserialize discoverable authentication state");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
).into_response()
})?;
let (_user_uuid, cred_id) = state
.webauthn_config
.identify_discoverable_authentication(credential)
.map_err(|e| {
tracing::warn!(error = %e, "Failed to identify discoverable credential");
(
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Passkey verification failed."
})),
)
.into_response()
})?;
let stored_passkey = state
.repos
.user
.get_passkey_by_credential_id(cred_id)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to look up passkey by credential ID");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
).into_response()
})?
.ok_or_else(|| {
tracing::warn!("Discoverable credential not found in database");
(
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Passkey not recognized."
})),
).into_response()
})?;
let discoverable_key: webauthn_rs::prelude::DiscoverableKey =
serde_json::from_slice(&stored_passkey.public_key).map_err(|e| {
tracing::error!(error = %e, "Failed to deserialize stored passkey as DiscoverableKey");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
).into_response()
})?;
let auth_result = state
.webauthn_config
.finish_discoverable_authentication(credential, auth_state, &[discoverable_key])
.map_err(|e| {
tracing::warn!(error = %e, did = %stored_passkey.did, "Failed to verify discoverable passkey authentication");
(
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Passkey verification failed."
})),
).into_response()
})?;
let _ = state
.repos
.user
.delete_discoverable_challenge(request_id.as_str())
.await;
Ok((stored_passkey.did, auth_result))
}
#[derive(Debug, Deserialize)]
pub struct AuthorizePasskeyQuery {
pub request_uri: String,
@@ -160,8 +160,10 @@ pub async fn register_complete(
.into_response();
}
let is_verified = match state.repos.user.get_session_info_by_did(&did).await {
Ok(Some(info)) => info.channel_verification.has_any_verified(),
let login_blocked = match state.repos.user.get_session_info_by_did(&did).await {
Ok(Some(info)) => {
tranquil_api::server::verification_blocks_login(&info.channel_verification)
}
Ok(None) => {
return (
StatusCode::FORBIDDEN,
@@ -189,7 +191,7 @@ pub async fn register_complete(
}
};
if !is_verified {
if login_blocked {
let resend_info = tranquil_api::server::auto_resend_verification(&state, &did).await;
return (
StatusCode::FORBIDDEN,
@@ -300,6 +302,7 @@ pub async fn register_complete(
pub async fn establish_session(
State(state): State<AppState>,
headers: HeaderMap,
client_ip: ClientIp,
auth: tranquil_pds::auth::Auth<tranquil_pds::auth::Active>,
) -> Response {
let did = &auth.did;
@@ -317,7 +320,7 @@ pub async fn establish_session(
let device_data = DeviceData {
session_id: SessionId::generate(),
user_agent: extract_user_agent(&headers),
ip_address: extract_client_ip(&headers, None),
ip_address: client_ip.into_string(),
last_seen_at: Utc::now(),
};
@@ -75,6 +75,7 @@ pub async fn authorize_2fa_post(
State(state): State<AppState>,
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
headers: HeaderMap,
client_ip: ClientIp,
Json(form): Json<Authorize2faSubmit>,
) -> Response {
let json_error = |status: StatusCode, error: &str, description: &str| -> Response {
@@ -251,7 +252,7 @@ pub async fn authorize_2fa_post(
let device_data = DeviceData {
session_id: SessionId::generate(),
user_agent: extract_user_agent(&headers),
ip_address: extract_client_ip(&headers, None),
ip_address: client_ip.into_string(),
last_seen_at: Utc::now(),
};
if state
@@ -12,7 +12,7 @@ use tranquil_pds::oauth::client::{build_client_metadata, delegation_oauth_urls};
use tranquil_pds::rate_limit::{LoginLimit, OAuthRateLimited, TotpVerifyLimit};
use tranquil_pds::state::AppState;
use tranquil_pds::types::PlainPassword;
use tranquil_pds::util::extract_client_ip;
use tranquil_pds::util::ClientIp;
use tranquil_types::did_doc::{extract_handle, extract_pds_endpoint};
use tranquil_types::{Did, RequestId};
@@ -402,6 +402,7 @@ pub struct DelegationTokenAuthSubmit {
pub async fn delegation_auth_token(
State(state): State<AppState>,
headers: HeaderMap,
client_ip: ClientIp,
auth: Auth<Active>,
Json(form): Json<DelegationTokenAuthSubmit>,
) -> Response {
@@ -428,7 +429,7 @@ pub async fn delegation_auth_token(
return resp;
}
let ip = extract_client_ip(&headers, None);
let ip = client_ip.into_string();
let user_agent = tranquil_pds::util::extract_user_agent(&headers);
finalize_delegation_auth(
@@ -138,21 +138,29 @@ pub async fn pushed_authorization_request(
}
fn determine_client_auth(request: &ParRequest) -> Result<ClientAuth, OAuthError> {
if let (Some(assertion), Some(assertion_type)) =
(&request.client_assertion, &request.client_assertion_type)
{
let assertion = request
.client_assertion
.as_deref()
.filter(|s| !s.is_empty());
let assertion_type = request
.client_assertion_type
.as_deref()
.filter(|s| !s.is_empty());
let secret = request.client_secret.as_deref().filter(|s| !s.is_empty());
if let (Some(assertion), Some(assertion_type)) = (assertion, assertion_type) {
if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
return Err(OAuthError::InvalidRequest(
"Unsupported client_assertion_type".to_string(),
));
}
return Ok(ClientAuth::PrivateKeyJwt {
client_assertion: assertion.clone(),
client_assertion: assertion.to_string(),
});
}
if let Some(secret) = &request.client_secret {
if let Some(secret) = secret {
return Ok(ClientAuth::SecretPost {
client_secret: secret.clone(),
client_secret: secret.to_string(),
});
}
Ok(ClientAuth::None)
@@ -138,13 +138,17 @@ impl TokenRequest {
}
};
let client_auth = match (self.client_assertion, self.client_assertion_type) {
let assertion = self.client_assertion.filter(|s| !s.is_empty());
let assertion_type = self.client_assertion_type.filter(|s| !s.is_empty());
let client_secret = self.client_secret.filter(|s| !s.is_empty());
let client_auth = match (assertion, assertion_type) {
(Some(assertion), Some(assertion_type)) => RequestClientAuth::PrivateKeyJwt {
client_id: self.client_id,
assertion,
assertion_type,
},
_ => match self.client_secret {
_ => match client_secret {
Some(secret) => RequestClientAuth::SecretPost {
client_id: self.client_id,
client_secret: secret,
@@ -402,13 +402,15 @@ async fn handle_sso_login(
}
};
let is_verified = match state
let login_blocked = match state
.repos
.user
.get_session_info_by_did(&identity.did)
.await
{
Ok(Some(info)) => info.channel_verification.has_any_verified(),
Ok(Some(info)) => {
tranquil_api::server::verification_blocks_login(&info.channel_verification)
}
Ok(None) => {
tracing::error!("User not found for SSO login: {}", identity.did);
return redirect_to_error("Account not found");
@@ -419,7 +421,7 @@ async fn handle_sso_login(
}
};
if !is_verified {
if login_blocked {
tracing::warn!(
did = %identity.did,
provider = %provider.as_str(),
@@ -1339,7 +1341,7 @@ pub async fn complete_registration(
refresh_expires_at: refresh_meta.expires_at,
login_type: tranquil_db_traits::LoginType::Modern,
mfa_verified: false,
scope: Some("transition:generic".to_string()),
scope: Some("transition:generic transition:chat.bsky".to_string()),
controller_did: None,
app_password_name: None,
};
+3
View File
@@ -23,3 +23,6 @@ sha2 = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }
[features]
native-tls-roots = []
+15 -12
View File
@@ -78,18 +78,21 @@ impl ClientMetadataCache {
Self {
cache: Arc::new(RwLock::new(HashMap::new())),
jwks_cache: Arc::new(RwLock::new(HashMap::new())),
http_client: Client::builder()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.pool_max_idle_per_host(10)
.pool_idle_timeout(std::time::Duration::from_secs(90))
.user_agent(concat!(
"Tranquil-PDS/",
env!("CARGO_PKG_VERSION"),
" (ATProto; +https://tangled.org/tranquil.farm/tranquil-pds)"
))
.build()
.unwrap_or_else(|_| Client::new()),
http_client: {
let builder = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.pool_max_idle_per_host(10)
.pool_idle_timeout(std::time::Duration::from_secs(90))
.user_agent(concat!(
"Tranquil-PDS/",
env!("CARGO_PKG_VERSION"),
" (ATProto; +https://tangled.org/tranquil.farm/tranquil-pds)"
));
#[cfg(feature = "native-tls-roots")]
let builder = builder.danger_accept_invalid_certs(true);
builder.build().unwrap_or_else(|_| Client::new())
},
cache_ttl_secs,
}
}
+3
View File
@@ -55,6 +55,7 @@ metrics-exporter-prometheus = { workspace = true }
multibase = { workspace = true }
multihash = { workspace = true }
p256 = { workspace = true }
parking_lot = { workspace = true }
rand = { workspace = true }
redis = { workspace = true, optional = true }
regex = { workspace = true }
@@ -78,6 +79,7 @@ tracing = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true }
webauthn-rs = { workspace = true }
webauthn-rs-proto = { workspace = true }
zip = { workspace = true }
aws-config = { workspace = true, optional = true }
aws-sdk-s3 = { workspace = true, optional = true }
@@ -89,6 +91,7 @@ s3-storage = ["tranquil-storage/s3", "dep:aws-config", "dep:aws-sdk-s3"]
s3 = ["s3-storage"]
valkey = ["tranquil-cache/valkey", "dep:redis"]
frontend = []
native-tls-roots = ["tranquil-oauth/native-tls-roots"]
[dev-dependencies]
ciborium = { workspace = true }
+42 -4
View File
@@ -21,6 +21,8 @@ pub enum ApiError {
InvalidToken(Option<String>),
ExpiredToken(Option<String>),
OAuthExpiredToken(Option<String>),
UseDpopNonce(String),
InvalidDpopProof(String),
TokenRequired,
AccountDeactivated,
AccountTakedown,
@@ -137,6 +139,8 @@ impl ApiError {
| Self::InvalidToken(_)
| Self::PasskeyCounterAnomaly
| Self::OAuthExpiredToken(_)
| Self::UseDpopNonce(_)
| Self::InvalidDpopProof(_)
| Self::ReauthRequired { .. } => StatusCode::UNAUTHORIZED,
Self::InvalidCode(_) => StatusCode::BAD_REQUEST,
Self::ExpiredToken(_) => StatusCode::BAD_REQUEST,
@@ -236,6 +240,8 @@ impl ApiError {
Self::AuthenticationFailed(_) => Cow::Borrowed("AuthenticationFailed"),
Self::InvalidToken(_) => Cow::Borrowed("InvalidToken"),
Self::ExpiredToken(_) | Self::OAuthExpiredToken(_) => Cow::Borrowed("ExpiredToken"),
Self::UseDpopNonce(_) => Cow::Borrowed("use_dpop_nonce"),
Self::InvalidDpopProof(_) => Cow::Borrowed("invalid_dpop_proof"),
Self::TokenRequired => Cow::Borrowed("TokenRequired"),
Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"),
Self::AccountTakedown => Cow::Borrowed("AccountTakedown"),
@@ -335,6 +341,8 @@ impl ApiError {
Self::ExpiredToken(msg) | Self::OAuthExpiredToken(msg) => {
msg.clone().unwrap_or_else(|| "Token has expired".into())
}
Self::UseDpopNonce(_) => "DPoP nonce required".into(),
Self::InvalidDpopProof(msg) => msg.clone(),
Self::RepoNotFound(msg) => msg
.clone()
.unwrap_or_else(|| "Repository not found".into()),
@@ -560,6 +568,36 @@ impl IntoResponse for ApiError {
),
);
}
Self::UseDpopNonce(nonce) => {
match HeaderValue::from_str(nonce) {
Ok(val) => {
response
.headers_mut()
.insert(crate::util::HEADER_DPOP_NONCE, val);
}
Err(err) => {
tracing::error!(
?err,
nonce_len = nonce.len(),
"generated DPoP nonce is not a valid header value"
);
}
}
response.headers_mut().insert(
http::header::WWW_AUTHENTICATE,
HeaderValue::from_static(
"DPoP error=\"use_dpop_nonce\", error_description=\"Resource server requires nonce in DPoP proof\"",
),
);
}
Self::InvalidDpopProof(_) => {
response.headers_mut().insert(
http::header::WWW_AUTHENTICATE,
HeaderValue::from_static(
"DPoP error=\"invalid_dpop_proof\", error_description=\"Invalid DPoP proof\"",
),
);
}
_ => {}
}
response
@@ -596,6 +634,8 @@ impl From<crate::auth::TokenValidationError> for ApiError {
crate::auth::TokenValidationError::InvalidToken => {
Self::AuthenticationFailed(Some("Invalid token format".to_string()))
}
crate::auth::TokenValidationError::UseDpopNonce(nonce) => Self::UseDpopNonce(nonce),
crate::auth::TokenValidationError::InvalidDpopProof(msg) => Self::InvalidDpopProof(msg),
}
}
}
@@ -625,10 +665,8 @@ impl From<crate::auth::extractor::AuthError> for ApiError {
crate::auth::extractor::AuthError::OAuthExpiredToken(msg) => {
Self::OAuthExpiredToken(Some(msg))
}
crate::auth::extractor::AuthError::UseDpopNonce(_)
| crate::auth::extractor::AuthError::InvalidDpopProof(_) => {
Self::AuthenticationFailed(None)
}
crate::auth::extractor::AuthError::UseDpopNonce(nonce) => Self::UseDpopNonce(nonce),
crate::auth::extractor::AuthError::InvalidDpopProof(msg) => Self::InvalidDpopProof(msg),
}
}
}
@@ -287,8 +287,6 @@ pub fn is_valid_discord_username(username: &str) -> bool {
&& username
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.')
&& !username.starts_with('.')
&& !username.ends_with('.')
&& !username.contains("..")
}
@@ -493,8 +491,6 @@ mod tests {
assert!(!is_valid_discord_username("Alice"));
assert!(!is_valid_discord_username("ALICE"));
assert!(!is_valid_discord_username("user-name"));
assert!(!is_valid_discord_username(".username"));
assert!(!is_valid_discord_username("username."));
assert!(!is_valid_discord_username("user..name"));
assert!(!is_valid_discord_username("user name"));
assert!(!is_valid_discord_username(
@@ -21,6 +21,13 @@ pub async fn require_verified_or_delegated<'a>(
state: &AppState,
user: &'a AuthenticatedUser,
) -> Result<AccountVerified<'a>, ApiError> {
if tranquil_config::get()
.server
.disable_account_verification_gate
{
return Ok(AccountVerified { user });
}
let is_verified = state
.repos
.user
+2 -27
View File
@@ -2,7 +2,7 @@ use std::marker::PhantomData;
use axum::{
extract::{FromRequestParts, OptionalFromRequestParts, OriginalUri},
http::{StatusCode, header::AUTHORIZATION, request::Parts},
http::{header::AUTHORIZATION, request::Parts},
response::{IntoResponse, Response},
};
use tracing::{debug, error, info};
@@ -35,32 +35,7 @@ pub enum AuthError {
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
match self {
Self::UseDpopNonce(nonce) => (
StatusCode::UNAUTHORIZED,
[
("DPoP-Nonce", nonce.as_str()),
("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\""),
],
axum::Json(serde_json::json!({
"error": "use_dpop_nonce",
"message": "DPoP nonce required"
})),
)
.into_response(),
Self::OAuthExpiredToken(msg) => ApiError::OAuthExpiredToken(Some(msg)).into_response(),
Self::InvalidDpopProof(msg) => (
StatusCode::UNAUTHORIZED,
[("WWW-Authenticate", "DPoP error=\"invalid_dpop_proof\"")],
axum::Json(serde_json::json!({
"error": "invalid_dpop_proof",
"message": msg
})),
)
.into_response(),
Self::InsufficientScope(msg) => ApiError::InsufficientScope(Some(msg)).into_response(),
other => ApiError::from(other).into_response(),
}
ApiError::from(self).into_response()
}
}
+22 -1
View File
@@ -156,6 +156,7 @@ pub enum Legacy2faOutcome {
}
pub struct Legacy2faContext {
pub is_app_password: bool,
pub email_2fa_enabled: bool,
pub has_totp: bool,
pub allow_legacy_login: bool,
@@ -163,7 +164,7 @@ pub struct Legacy2faContext {
impl Legacy2faContext {
pub fn requires_2fa(&self) -> bool {
self.email_2fa_enabled || self.has_totp
!self.is_app_password && (self.email_2fa_enabled || self.has_totp)
}
pub fn is_blocked(&self) -> bool {
@@ -418,6 +419,7 @@ mod tests {
let cache = MockCache::new();
let did = Did::new("did:plc:test".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: false,
has_totp: false,
allow_legacy_login: true,
@@ -427,11 +429,27 @@ mod tests {
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
}
#[tokio::test]
async fn test_process_flow_not_required_because_app_password() {
let cache = MockCache::new();
let did = Did::new("did:plc:test".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: true,
email_2fa_enabled: false,
has_totp: true,
allow_legacy_login: true,
};
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
}
#[tokio::test]
async fn test_process_flow_blocked() {
let cache = MockCache::new();
let did = Did::new("did:plc:test".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: false,
has_totp: true,
allow_legacy_login: false,
@@ -446,6 +464,7 @@ mod tests {
let cache = MockCache::new();
let did = Did::new("did:plc:test".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: false,
has_totp: true,
allow_legacy_login: true,
@@ -460,6 +479,7 @@ mod tests {
let cache = MockCache::new();
let did = Did::new("did:plc:test2".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: true,
has_totp: false,
allow_legacy_login: false,
@@ -474,6 +494,7 @@ mod tests {
let cache = MockCache::new();
let did = Did::new("did:plc:test".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: true,
has_totp: false,
allow_legacy_login: false,
+11 -1
View File
@@ -106,7 +106,7 @@ struct CachedUserStatus {
is_admin: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TokenValidationError {
AccountDeactivated,
AccountTakedown,
@@ -115,6 +115,8 @@ pub enum TokenValidationError {
TokenExpired,
OAuthTokenExpired,
InvalidToken,
UseDpopNonce(String),
InvalidDpopProof(String),
}
impl fmt::Display for TokenValidationError {
@@ -126,6 +128,8 @@ impl fmt::Display for TokenValidationError {
Self::AuthenticationFailed => write!(f, "AuthenticationFailed"),
Self::TokenExpired | Self::OAuthTokenExpired => write!(f, "ExpiredToken"),
Self::InvalidToken => write!(f, "InvalidToken"),
Self::UseDpopNonce(_) => write!(f, "use_dpop_nonce"),
Self::InvalidDpopProof(_) => write!(f, "invalid_dpop_proof"),
}
}
}
@@ -613,6 +617,12 @@ pub async fn validate_token_with_dpop(
Err(crate::oauth::OAuthError::ExpiredToken(_)) => {
Err(TokenValidationError::OAuthTokenExpired)
}
Err(crate::oauth::OAuthError::UseDpopNonce(nonce)) => {
Err(TokenValidationError::UseDpopNonce(nonce))
}
Err(crate::oauth::OAuthError::InvalidDpopProof(msg)) => {
Err(TokenValidationError::InvalidDpopProof(msg))
}
Err(_) => Err(TokenValidationError::AuthenticationFailed),
}
}
+57
View File
@@ -1,5 +1,8 @@
use uuid::Uuid;
use webauthn_rs::prelude::*;
use webauthn_rs_proto::{
AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationPolicy,
};
#[derive(Debug, thiserror::Error)]
pub enum WebauthnError {
@@ -57,6 +60,15 @@ impl WebAuthnConfig {
None,
None,
)
.map(|(mut ccr, state)| {
let sel = ccr
.public_key
.authenticator_selection
.get_or_insert_with(AuthenticatorSelectionCriteria::default);
sel.resident_key = Some(ResidentKeyRequirement::Required);
sel.require_resident_key = true;
(ccr, state)
})
.map_err(|e| WebauthnError::RegistrationFailed(e.to_string()))
}
@@ -88,4 +100,49 @@ impl WebAuthnConfig {
.finish_securitykey_authentication(auth, state)
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))
}
pub fn start_discoverable_authentication(
&self,
) -> Result<(RequestChallengeResponse, DiscoverableAuthentication), WebauthnError> {
let (mut rcr, state) = self
.webauthn
.start_discoverable_authentication()
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))?;
rcr.mediation = None;
rcr.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE;
let mut state_json = serde_json::to_value(&state)
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))?;
let ast = state_json
.get_mut("ast")
.ok_or_else(|| WebauthnError::AuthenticationFailed(
"webauthn-rs DiscoverableAuthentication missing 'ast' field, library version incompatible".into(),
))?;
ast["policy"] = serde_json::json!("discouraged");
let patched: DiscoverableAuthentication = serde_json::from_value(state_json)
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))?;
Ok((rcr, patched))
}
pub fn identify_discoverable_authentication<'a>(
&self,
credential: &'a PublicKeyCredential,
) -> Result<(Uuid, &'a [u8]), WebauthnError> {
self.webauthn
.identify_discoverable_authentication(credential)
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))
}
pub fn finish_discoverable_authentication(
&self,
credential: &PublicKeyCredential,
state: DiscoverableAuthentication,
creds: &[DiscoverableKey],
) -> Result<AuthenticationResult, WebauthnError> {
self.webauthn
.finish_discoverable_authentication(credential, state, creds)
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ pub use tranquil_comms::{
CommsChannel, CommsSender, CommsStatus, CommsType, DEFAULT_LOCALE, DiscordSender, EmailSender,
NewComms, NotificationStrings, QueuedComms, SendError, SignalSender, TelegramSender,
VALID_LOCALES, format_message, get_strings, is_valid_phone_number, is_valid_signal_username,
mime_encode_header, sanitize_header_value, validate_locale,
validate_locale,
};
pub use service::{CommsService, repo as comms_repo, resolve_delivery_channel};
+35 -10
View File
@@ -3,7 +3,7 @@ use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use tokio::time::interval;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
use tranquil_comms::{
@@ -75,17 +75,28 @@ impl CommsService {
);
}
info!(
poll_interval_secs = self.poll_interval.as_secs(),
poll_interval_ms = self.poll_interval.as_millis() as u64,
batch_size = self.batch_size,
channels = ?self.senders.keys().collect::<Vec<_>>(),
"Starting comms service"
);
let mut ticker = interval(self.poll_interval);
let base = self.poll_interval;
let max_backoff = Duration::from_secs(30);
let mut current_delay = base;
loop {
tokio::select! {
_ = ticker.tick() => {
if let Err(e) = self.process_batch().await {
error!(error = %e, "Failed to process comms batch");
_ = tokio::time::sleep(current_delay) => {
match self.process_batch().await {
Ok(had_work) => {
current_delay = match had_work {
true => base,
false => max_backoff.min(current_delay.saturating_mul(2)),
};
}
Err(e) => {
error!(error = %e, "Failed to process comms batch");
current_delay = max_backoff.min(current_delay.saturating_mul(2));
}
}
}
_ = shutdown.cancelled() => {
@@ -96,14 +107,14 @@ impl CommsService {
}
}
async fn process_batch(&self) -> Result<(), tranquil_db_traits::DbError> {
async fn process_batch(&self) -> Result<bool, tranquil_db_traits::DbError> {
let items = self.fetch_pending().await?;
if items.is_empty() {
return Ok(());
return Ok(false);
}
debug!(count = items.len(), "Processing comms batch");
futures::future::join_all(items.into_iter().map(|item| self.process_item(item))).await;
Ok(())
Ok(true)
}
async fn fetch_pending(&self) -> Result<Vec<QueuedComms>, tranquil_db_traits::DbError> {
@@ -138,13 +149,19 @@ impl CommsService {
}
}
Err(e) => {
let permanent = e.is_permanent();
let error_msg = e.to_string();
warn!(
comms_id = %comms_id,
error = %error_msg,
permanent,
"Failed to send comms"
);
if let Err(db_err) = self.mark_failed(comms_id, &error_msg).await {
let db_result = match permanent {
true => self.mark_failed_permanent(comms_id, &error_msg).await,
false => self.mark_failed(comms_id, &error_msg).await,
};
if let Err(db_err) = db_result {
error!(
comms_id = %comms_id,
error = %db_err,
@@ -162,6 +179,14 @@ impl CommsService {
async fn mark_failed(&self, id: Uuid, error: &str) -> Result<(), tranquil_db_traits::DbError> {
self.infra_repo.mark_comms_failed(id, error).await
}
async fn mark_failed_permanent(
&self,
id: Uuid,
error: &str,
) -> Result<(), tranquil_db_traits::DbError> {
self.infra_repo.mark_comms_failed_permanent(id, error).await
}
}
struct ResolvedRecipient {
@@ -9,7 +9,7 @@ use axum::{
use crate::api::error::ApiError;
use crate::oauth::OAuthError;
use crate::state::{AppState, RateLimitKind};
use crate::util::extract_client_ip;
use crate::util::client_ip_from_parts;
pub trait RateLimitPolicy: Send + Sync + 'static {
const KIND: RateLimitKind;
@@ -173,7 +173,7 @@ impl<P: RateLimitPolicy, R: RateLimitRejection> FromRequestParts<AppState>
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let client_ip = extract_client_ip(&parts.headers, None);
let client_ip = client_ip_from_parts(parts);
if !state.check_rate_limit(P::KIND, &client_ip).await {
tracing::warn!(
+185 -98
View File
@@ -6,18 +6,19 @@ use crate::types::{Did, Handle, Nsid, Rkey};
use backon::{ExponentialBuilder, Retryable};
use bytes::Bytes;
use cid::Cid;
use jacquard_common::smol_str::SmolStr;
use jacquard_common::types::{integer::LimitedU32, string::Tid};
use jacquard_repo::commit::Commit;
use jacquard_repo::mst::Mst;
use jacquard_repo::mst::util::compute_cid;
use jacquard_repo::mst::{Mst, VerifiedWriteOp};
use jacquard_repo::storage::BlockStore;
use k256::ecdsa::SigningKey;
use serde_json::{Value, json};
use std::collections::BTreeSet;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::OwnedMutexGuard;
use tracing::error;
use tracing::{error, warn};
use tranquil_db_traits::SequenceNumber;
use uuid::Uuid;
@@ -39,6 +40,7 @@ pub enum CommitError {
MstOperationFailed(String),
RecordSerializationFailed(String),
InvalidCid(String),
RecordAlreadyExists(String),
}
impl std::fmt::Display for CommitError {
@@ -64,6 +66,7 @@ impl std::fmt::Display for CommitError {
write!(f, "Failed to serialize record: {}", e)
}
Self::InvalidCid(e) => write!(f, "Invalid CID: {}", e),
Self::RecordAlreadyExists(key) => write!(f, "Record already exists at {}", key),
}
}
}
@@ -78,6 +81,9 @@ impl From<CommitError> for ApiError {
}
CommitError::RepoNotFound => ApiError::RepoNotFound(None),
CommitError::UserNotFound => ApiError::RepoNotFound(Some("User not found".into())),
CommitError::RecordAlreadyExists(key) => {
ApiError::InvalidRequest(format!("Record already exists at {key}"))
}
other => {
error!("Commit failed: {}", other);
ApiError::InternalError(Some("Failed to commit changes".into()))
@@ -161,7 +167,6 @@ pub struct FinalizeParams<'a> {
pub controller_did: Option<&'a Did>,
pub delegation_detail: Option<serde_json::Value>,
pub ops: Vec<RecordOp>,
pub modified_keys: &'a [String],
pub blob_cids: &'a [String],
pub backlinks_to_add: Vec<Backlink>,
pub backlinks_to_remove: Vec<AtUri>,
@@ -226,30 +231,6 @@ pub async fn begin_repo_write(
Ok((ctx, mst))
}
pub async fn compute_obsolete_cids(
original_mst: &Mst<TrackingBlockStore>,
new_mst: &Mst<TrackingBlockStore>,
original_root_cid: CommitCid,
) -> Result<Vec<Cid>, jacquard_repo::error::RepoError> {
let (old_nodes, new_nodes, old_leaves, new_leaves) = tokio::try_join!(
original_mst.collect_node_cids(),
new_mst.collect_node_cids(),
original_mst.leaves(),
new_mst.leaves(),
)?;
let old_nodes_set: BTreeSet<Cid> = old_nodes.into_iter().collect();
let new_nodes_set: BTreeSet<Cid> = new_nodes.into_iter().collect();
let old_leaf_set: BTreeSet<Cid> = old_leaves.iter().map(|(_, cid)| *cid).collect();
let new_leaf_set: BTreeSet<Cid> = new_leaves.iter().map(|(_, cid)| *cid).collect();
let removed_nodes = old_nodes_set.difference(&new_nodes_set).copied();
let removed_leaves = old_leaf_set.difference(&new_leaf_set).copied();
let obsolete: BTreeSet<Cid> = std::iter::once(original_root_cid.into_cid())
.chain(removed_nodes)
.chain(removed_leaves)
.collect();
Ok(obsolete.into_iter().collect())
}
pub async fn finalize_repo_write(
state: &AppState,
ctx: RepoWriteContext,
@@ -261,40 +242,101 @@ pub async fn finalize_repo_write(
ApiError::InternalError(None)
})?;
let block_bytes = ctx.tracking_store.take_written_blocks();
let written_bytes = ctx.tracking_store.take_written_blocks();
let new_tree_cids: Vec<Cid> = written_bytes.keys().copied().collect();
let storage_for_diff = Arc::new(ctx.tracking_store.clone());
let original_settled = Mst::load(storage_for_diff.clone(), ctx.prev_data_cid, None);
let new_settled = Mst::load(storage_for_diff, new_mst_root, None);
let (obsolete_cids, new_tree_cids) = tokio::try_join!(
async {
compute_obsolete_cids(
&original_settled,
&new_settled,
CommitCid::from(ctx.current_root_cid),
)
let storage_for_proof = Arc::new(ctx.tracking_store.clone());
let original_settled = Mst::load(storage_for_proof.clone(), ctx.prev_data_cid, None);
let new_settled = Mst::load(storage_for_proof.clone(), new_mst_root, None);
let mut inverse_trace = new_settled.clone();
let mut non_invertible: Vec<String> = Vec::new();
let mut invert_errors: Vec<String> = Vec::new();
for op in params.ops.iter().rev() {
let (collection, rkey) = op.collection_rkey();
let key = SmolStr::new(format!("{}/{}", collection, rkey));
let verified = match op {
RecordOp::Create { cid, .. } => VerifiedWriteOp::Create {
key,
cid: *cid.as_cid(),
},
RecordOp::Update { cid, prev, .. } => VerifiedWriteOp::Update {
key,
cid: *cid.as_cid(),
prev: *prev.as_cid(),
},
RecordOp::Delete { prev, .. } => VerifiedWriteOp::Delete {
key,
prev: *prev.as_cid(),
},
};
match inverse_trace.invert_op(verified.clone()).await {
Ok(true) => {}
Ok(false) => non_invertible.push(format!("{:?}", verified)),
Err(e) => invert_errors.push(format!("{:?} -> {:?}", verified, e)),
}
}
if !non_invertible.is_empty() {
warn!(
user_id = %params.user_id,
count = non_invertible.len(),
ops = ?non_invertible,
"firehose proof walk: ops not invertible on new MST, consumer will reject frame"
);
}
if !invert_errors.is_empty() {
warn!(
user_id = %params.user_id,
count = invert_errors.len(),
failures = ?invert_errors,
"firehose proof walk: invert_op errored, cover blocks may be incomplete"
);
}
let read_cid_set: HashSet<Cid> = ctx.tracking_store.get_read_cids().into_iter().collect();
let missing_read_cids: Vec<Cid> = read_cid_set
.iter()
.copied()
.filter(|cid| !written_bytes.contains_key(cid))
.collect();
let mut relevant: BTreeMap<Cid, Bytes> = BTreeMap::new();
if !missing_read_cids.is_empty() {
let fetched = ctx
.tracking_store
.get_many(&missing_read_cids)
.await
.map_err(|e| {
error!("MST diff failed during finalize_repo_write: {}", e);
ApiError::InternalError(Some("MST diff failed".into()))
})
},
async {
let (nodes, leaves) =
tokio::try_join!(new_settled.collect_node_cids(), new_settled.leaves(),).map_err(
|e| {
error!("new tree walk failed: {}", e);
ApiError::InternalError(None)
},
)?;
Ok::<Vec<Cid>, ApiError>(
nodes
.into_iter()
.chain(leaves.iter().map(|(_, cid)| *cid))
.collect(),
)
},
)?;
error!("fetch cover read bytes: {e}");
ApiError::InternalError(None)
})?;
for (cid, maybe) in missing_read_cids.into_iter().zip(fetched) {
if let Some(bytes) = maybe {
relevant.insert(cid, bytes);
}
}
}
let obsolete_cids = match original_settled.diff(&new_settled).await {
Ok(diff) => {
let mut obsolete: Vec<Cid> =
Vec::with_capacity(1 + diff.removed_mst_blocks.len() + diff.removed_cids.len());
obsolete.push(ctx.current_root_cid);
obsolete.extend(diff.removed_mst_blocks);
obsolete.extend(diff.removed_cids);
obsolete
}
Err(e) => {
error!(
"MST diff failed during finalize_repo_write: {e}. \
Proceeding with commit CID only; leaked blocks \
will be reclaimed by reachability GC."
);
vec![ctx.current_root_cid]
}
};
let mut block_bytes = written_bytes;
block_bytes.extend(relevant);
let result = commit_and_log(
state,
@@ -379,6 +421,22 @@ pub enum RecordOp {
},
}
impl RecordOp {
pub fn collection_rkey(&self) -> (&Nsid, &Rkey) {
match self {
Self::Create {
collection, rkey, ..
}
| Self::Update {
collection, rkey, ..
}
| Self::Delete {
collection, rkey, ..
} => (collection, rkey),
}
}
}
pub struct CommitResult {
pub commit_cid: Cid,
pub rev: String,
@@ -409,8 +467,6 @@ pub async fn commit_and_log(
RecordUpsert, RepoEventType,
};
let backlinks_to_add = params.backlinks_to_add;
let backlinks_to_remove = params.backlinks_to_remove;
let CommitParams {
did,
user_id,
@@ -423,7 +479,8 @@ pub async fn commit_and_log(
new_tree_cids,
blobs,
obsolete_cids,
..
backlinks_to_add,
backlinks_to_remove,
} = params;
debug_assert_eq!(
current_root_cid.is_some(),
@@ -469,39 +526,63 @@ pub async fn commit_and_log(
let obsolete_bytes: Vec<Vec<u8>> = obsolete_cids.iter().map(|c| c.to_bytes()).collect();
let (record_upserts, record_deletes): (Vec<RecordUpsert>, Vec<RecordDelete>) = ops.iter().fold(
(Vec::new(), Vec::new()),
|(mut upserts, mut deletes), op| {
match op {
RecordOp::Create {
collection,
rkey,
cid,
}
| RecordOp::Update {
collection,
rkey,
cid,
..
} => {
upserts.push(RecordUpsert {
collection: collection.clone(),
rkey: rkey.clone(),
cid: crate::types::CidLink::from(cid.as_cid()),
});
}
RecordOp::Delete {
collection, rkey, ..
} => {
deletes.push(RecordDelete {
collection: collection.clone(),
rkey: rkey.clone(),
});
}
let final_ops: HashMap<(&Nsid, &Rkey), &RecordOp> =
ops.iter().map(|op| (op.collection_rkey(), op)).collect();
let final_record_uris: HashSet<AtUri> = final_ops
.iter()
.filter(|(_, op)| !matches!(op, RecordOp::Delete { .. }))
.map(|((c, r), _)| AtUri::from_parts(did, c, r))
.collect();
let record_upserts: Vec<RecordUpsert> = final_ops
.values()
.filter_map(|op| match op {
RecordOp::Create {
collection,
rkey,
cid,
}
(upserts, deletes)
},
);
| RecordOp::Update {
collection,
rkey,
cid,
..
} => Some(RecordUpsert {
collection: collection.clone(),
rkey: rkey.clone(),
cid: crate::types::CidLink::from(cid.as_cid()),
}),
RecordOp::Delete { .. } => None,
})
.collect();
let record_deletes: Vec<RecordDelete> = final_ops
.values()
.filter_map(|op| match op {
RecordOp::Delete {
collection, rkey, ..
} => Some(RecordDelete {
collection: collection.clone(),
rkey: rkey.clone(),
}),
_ => None,
})
.collect();
let backlinks_to_add: Vec<Backlink> = backlinks_to_add
.into_iter()
.filter(|b| final_record_uris.contains(&b.uri))
.map(|b| ((b.uri.clone(), b.path), b))
.collect::<HashMap<_, _>>()
.into_values()
.collect();
let backlinks_to_remove: Vec<AtUri> = backlinks_to_remove
.into_iter()
.collect::<HashSet<_>>()
.into_iter()
.collect();
let ops_json: Vec<serde_json::Value> = ops
.iter()
@@ -636,6 +717,16 @@ pub async fn create_record_internal(
.await
.map_err(to_commit_err)?;
let key = format!("{}/{}", collection, rkey);
if mst
.get(&key)
.await
.map_err(|e| CommitError::MstOperationFailed(e.to_string()))?
.is_some()
{
return Err(CommitError::RecordAlreadyExists(key));
}
let record_ipld = crate::util::json_to_ipld(record);
let mut record_bytes = Vec::new();
serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld)
@@ -645,8 +736,6 @@ pub async fn create_record_internal(
.put(&record_bytes)
.await
.map_err(|e| CommitError::BlockStoreFailed(e.to_string()))?;
let key = format!("{}/{}", collection, rkey);
let new_mst = mst
.add(&key, record_cid)
.await
@@ -657,7 +746,6 @@ pub async fn create_record_internal(
rkey: rkey.clone(),
cid: RecordCid::from(record_cid),
};
let modified_keys = [key];
let blob_cids = extract_blob_cids(record);
let record_uri = AtUri::from_parts(did.as_str(), collection.as_str(), rkey.as_str());
let backlinks = extract_backlinks(&record_uri, record);
@@ -672,7 +760,6 @@ pub async fn create_record_internal(
controller_did: None,
delegation_detail: None,
ops: vec![op],
modified_keys: &modified_keys,
blob_cids: &blob_cids,
backlinks_to_add: backlinks,
backlinks_to_remove: vec![],
+165 -27
View File
@@ -399,6 +399,10 @@ pub async fn start_scheduled_tasks(
let mut compaction_ticker = interval(compaction_interval);
compaction_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let compaction_blocklist = Arc::new(parking_lot::Mutex::new(CompactionBlocklist::new(
Duration::from_secs(300),
)));
let mut reachability_ticker = interval(reachability_interval);
reachability_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
@@ -464,8 +468,9 @@ pub async fn start_scheduled_tasks(
let store = store.clone();
let threshold = cfg.scheduled.compaction_liveness_threshold;
let grace_ms = cfg.scheduled.compaction_grace_period_ms;
let blocklist = Arc::clone(&compaction_blocklist);
if let Err(e) = tokio::task::spawn_blocking(move || {
run_compaction_pass(&store, threshold, grace_ms)
run_compaction_pass(&store, threshold, grace_ms, &blocklist)
}).await.unwrap_or_else(|e| Err(anyhow::anyhow!("compaction task panicked: {e}"))) {
error!("Compaction error: {e}");
}
@@ -485,6 +490,8 @@ pub async fn start_scheduled_tasks(
live_refcounted = result.live_refcounted,
leaked_blocks = result.leaked_blocks,
repaired_blocks = result.repaired_blocks,
phantom_files_purged = result.phantom_files_purged,
phantom_blocks_purged = result.phantom_blocks_purged,
bloom_heap_mb = result.bloom_heap_bytes / (1024 * 1024),
"reachability walk complete"
);
@@ -536,11 +543,44 @@ pub async fn start_scheduled_tasks(
}
}
pub struct CompactionBlocklist {
entries: std::collections::HashMap<tranquil_store::blockstore::DataFileId, std::time::Instant>,
cool_off: Duration,
}
impl CompactionBlocklist {
pub fn new(cool_off: Duration) -> Self {
Self {
entries: std::collections::HashMap::new(),
cool_off,
}
}
pub fn record_failure(&mut self, file_id: tranquil_store::blockstore::DataFileId) {
self.entries.insert(file_id, std::time::Instant::now());
}
pub fn is_blocked(&self, file_id: tranquil_store::blockstore::DataFileId) -> bool {
self.entries
.get(&file_id)
.is_some_and(|recorded| recorded.elapsed() < self.cool_off)
}
pub fn prune_expired(&mut self) {
let cool_off = self.cool_off;
self.entries
.retain(|_, recorded| recorded.elapsed() < cool_off);
}
}
fn run_compaction_pass(
store: &tranquil_store::blockstore::TranquilBlockStore,
liveness_threshold: f64,
grace_period_ms: u64,
blocklist: &parking_lot::Mutex<CompactionBlocklist>,
) -> anyhow::Result<()> {
blocklist.lock().prune_expired();
match store.cleanup_gc_meta() {
Ok(0) => {}
Ok(n) => info!(count = n, "cleaned up stale gc_meta entries"),
@@ -553,7 +593,11 @@ fn run_compaction_pass(
let candidate = liveness_map
.iter()
.filter(|(_, info)| info.total_blocks > 0 && info.ratio() < liveness_threshold)
.filter(|(fid, info)| {
info.total_blocks > 0
&& info.ratio() < liveness_threshold
&& !blocklist.lock().is_blocked(**fid)
})
.min_by(|(_, a), (_, b)| {
a.ratio()
.partial_cmp(&b.ratio())
@@ -574,21 +618,35 @@ fn run_compaction_pass(
"compacting data file"
);
match store.compact_file(file_id, grace_period_ms) {
Ok(result) => {
Ok(tranquil_store::blockstore::CompactionResult::Compacted(stats)) => {
info!(
file_id = %result.file_id,
reclaimed_bytes = result.reclaimed_bytes,
live_blocks = result.live_blocks,
dead_blocks = result.dead_blocks,
file_id = %stats.file_id,
reclaimed_bytes = stats.reclaimed_bytes,
live_blocks = stats.live_blocks,
dead_blocks = stats.dead_blocks,
"compaction complete"
);
Ok(())
}
Ok(tranquil_store::blockstore::CompactionResult::Purged {
file_id,
phantom_blocks,
}) => {
warn!(
file_id = %file_id,
phantom_blocks,
"compaction target missing on disk, purged phantom index entries"
);
Ok(())
}
Err(tranquil_store::blockstore::CompactionError::ActiveFileCannotBeCompacted) => {
debug!(file_id = %file_id, "skipped active file");
Ok(())
}
Err(e) => Err(anyhow::anyhow!("compaction failed: {e}")),
Err(e) => {
blocklist.lock().record_failure(file_id);
Err(anyhow::anyhow!("compaction failed: {e}"))
}
}
}
}
@@ -667,6 +725,8 @@ async fn delete_account_data(
Ok(())
}
const CAR_BLOCK_BATCH_SIZE: usize = 500;
pub async fn generate_repo_car(
block_store: &AnyBlockStore,
head_cid: &Cid,
@@ -683,21 +743,38 @@ pub async fn generate_repo_car(
})
.collect();
let car_bytes = encode_car_header(head_cid).context("Failed to encode CAR header")?;
let mut car_bytes = encode_car_header(head_cid).context("Failed to encode CAR header")?;
let blocks = block_store
.get_many(&block_cids)
.await
.context("Failed to fetch blocks")?;
for chunk in block_cids.chunks(CAR_BLOCK_BATCH_SIZE) {
let blocks = block_store
.get_many(chunk)
.await
.context("Failed to fetch blocks")?;
let car_bytes = block_cids
.iter()
.zip(blocks.iter())
.filter_map(|(cid, block_opt)| block_opt.as_ref().map(|block| (cid, block)))
.fold(car_bytes, |mut acc, (cid, block)| {
acc.extend(encode_car_block(cid, block));
acc
});
let missing: Vec<Cid> = chunk
.iter()
.zip(blocks.iter())
.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(", ")
);
}
chunk
.iter()
.zip(blocks.iter())
.filter_map(|(cid, block_opt)| block_opt.as_ref().map(|block| (cid, block)))
.for_each(|(cid, block)| car_bytes.extend(encode_car_block(cid, block)));
}
Ok(car_bytes)
}
@@ -745,6 +822,8 @@ pub struct ReachabilityResult {
pub leaked_blocks: u64,
pub repaired_blocks: u64,
pub bloom_heap_bytes: usize,
pub phantom_files_purged: u64,
pub phantom_blocks_purged: u64,
}
const REPO_PAGE_SIZE: i64 = 500;
@@ -760,6 +839,7 @@ fn walk_repo_dag_sync(
store: &tranquil_store::blockstore::TranquilBlockStore,
head_cid: &Cid,
reachable: &mut std::collections::HashSet<CidBytes>,
phantom_files: &mut std::collections::HashSet<tranquil_store::blockstore::DataFileId>,
) -> anyhow::Result<()> {
let mut to_visit = vec![cid_to_bytes(head_cid)?];
@@ -768,15 +848,56 @@ fn walk_repo_dag_sync(
continue;
}
let block = match store.get_block_sync(&cid_bytes)? {
Some(b) => b,
None => {
let block = match store.get_block_sync(&cid_bytes) {
Ok(Some(b)) => b,
Ok(None) => {
tracing::warn!(
?cid_bytes,
"referenced block missing during reachability walk"
);
continue;
}
Err(e) => {
let Some(entry) = store.block_index().get(&cid_bytes) else {
tracing::warn!(
?cid_bytes,
error = %e,
"reachability walk: index entry vanished between read attempt and re-check"
);
continue;
};
let file_path = store.data_file_path(entry.location.file_id);
match file_path.try_exists() {
Ok(false) => {
tracing::warn!(
?cid_bytes,
file_id = %entry.location.file_id,
error = %e,
"indexed block points at missing data file, scheduling phantom purge"
);
phantom_files.insert(entry.location.file_id);
continue;
}
Ok(true) => {
return Err(anyhow::anyhow!(
"reachability walk read error on present data file {}: {e}",
entry.location.file_id
));
}
Err(probe_err) => {
tracing::warn!(
?cid_bytes,
file_id = %entry.location.file_id,
existence_probe_error = %probe_err,
"could not probe data file existence after read error"
);
return Err(anyhow::anyhow!(
"reachability walk read error on file {}: {e}",
entry.location.file_id
));
}
}
}
};
if let Ok(commit) = Commit::from_cbor(&block) {
@@ -857,13 +978,15 @@ pub fn run_reachability_walk(
let mut repos_walked: u64 = 0;
let mut seen_heads: std::collections::HashMap<Did, CidLink> = std::collections::HashMap::new();
let mut phantom_files: std::collections::HashSet<tranquil_store::blockstore::DataFileId> =
std::collections::HashSet::new();
paginate_repos(&rt, repo_repo, |page| {
page.iter().try_for_each(|repo| -> anyhow::Result<()> {
let cid =
Cid::from_str(repo.repo_root_cid.as_str()).context("invalid repo_root_cid")?;
seen_heads.insert(repo.did.clone(), repo.repo_root_cid.clone());
walk_repo_dag_sync(store, &cid, &mut visited)?;
walk_repo_dag_sync(store, &cid, &mut visited, &mut phantom_files)?;
repos_walked = repos_walked.saturating_add(1);
if repos_walked.is_multiple_of(1000) {
info!(
@@ -893,7 +1016,7 @@ pub fn run_reachability_walk(
let cid =
Cid::from_str(repo.repo_root_cid.as_str()).context("invalid repo_root_cid")?;
let mut extra = std::collections::HashSet::new();
walk_repo_dag_sync(store, &cid, &mut extra)?;
walk_repo_dag_sync(store, &cid, &mut extra, &mut phantom_files)?;
extra.iter().for_each(|c| reachable.insert(c));
seen_heads.insert(repo.did.clone(), repo.repo_root_cid.clone());
stale_repos = stale_repos.saturating_add(1);
@@ -921,7 +1044,7 @@ pub fn run_reachability_walk(
let cid =
Cid::from_str(repo.repo_root_cid.as_str()).context("invalid repo_root_cid")?;
let mut extra = std::collections::HashSet::new();
walk_repo_dag_sync(store, &cid, &mut extra)?;
walk_repo_dag_sync(store, &cid, &mut extra, &mut phantom_files)?;
extra.iter().for_each(|c| reachable.insert(c));
quiesced_stale = quiesced_stale.saturating_add(1);
Ok(())
@@ -957,6 +1080,19 @@ pub fn run_reachability_walk(
}
};
let phantom_files_purged = u64::try_from(phantom_files.len()).unwrap_or(u64::MAX);
let phantom_blocks_purged = phantom_files
.iter()
.map(|fid| store.block_index().purge_by_file_id(*fid))
.sum::<u64>();
if phantom_files_purged > 0 {
warn!(
phantom_files_purged,
phantom_blocks_purged, "purged phantom index entries from unreadable data files"
);
}
Ok(ReachabilityResult {
repos_walked,
blocks_visited,
@@ -964,5 +1100,7 @@ pub fn run_reachability_walk(
leaked_blocks,
repaired_blocks,
bloom_heap_bytes,
phantom_files_purged,
phantom_blocks_purged,
})
}
+52 -29
View File
@@ -50,6 +50,7 @@ pub struct AppState {
pub signal_sender: Option<Arc<tranquil_signal::SignalSlot>>,
pub signal_store_provider: Option<Arc<dyn tranquil_signal::SignalStoreProvider>>,
pub eventlog_segments_dir: Option<PathBuf>,
pub repo_export_semaphore: Arc<tokio::sync::Semaphore>,
}
#[derive(Debug, Clone, Copy)]
@@ -213,10 +214,10 @@ impl AppState {
pub async fn new(shutdown: CancellationToken) -> Result<Self, Box<dyn Error>> {
let cfg = tranquil_config::get();
match cfg.storage.repo_backend() {
let mut state = match cfg.storage.repo_backend() {
tranquil_config::RepoBackend::TranquilStore => {
tracing::info!("tranquil-store repo backend active. EXPERIMENTAL!");
Ok(Self::from_store(shutdown).await)
Self::from_store(shutdown).await
}
tranquil_config::RepoBackend::Postgres => {
let database_url = &cfg.database.url;
@@ -246,28 +247,21 @@ impl AppState {
.await
.map_err(|e| format!("Failed to run migrations: {}", e))?;
let bootstrap_invite_code = match (
cfg.server.invite_code_required,
sqlx::query_scalar!("SELECT COUNT(*) FROM users")
.fetch_one(&db)
.await,
) {
(true, Ok(Some(0))) => {
let code = crate::util::gen_invite_code();
tracing::info!(
"No users exist and invite codes are required. Bootstrap invite code: {}",
code
);
Some(code)
}
_ => None,
};
let mut state = Self::from_db(db, shutdown).await;
state.bootstrap_invite_code = bootstrap_invite_code;
Ok(state)
Self::from_db(db, shutdown).await
}
};
if cfg.server.invite_code_required && state.repos.user.count_users().await.unwrap_or(1) == 0
{
let code = crate::util::gen_invite_code();
tracing::info!(
"No users exist and invite codes are required. Bootstrap invite code: {}",
code
);
state.bootstrap_invite_code = Some(code);
}
Ok(state)
}
pub async fn from_db(db: PgPool, shutdown: CancellationToken) -> Self {
@@ -394,6 +388,9 @@ impl AppState {
signal_sender: None,
signal_store_provider,
eventlog_segments_dir,
repo_export_semaphore: Arc::new(tokio::sync::Semaphore::new(
cfg.firehose.max_concurrent_repo_exports,
)),
}
}
@@ -519,13 +516,16 @@ fn wire_tranquil_store(
let metastore =
Metastore::open(&metastore_dir, metastore_config).expect("failed to open metastore");
let blockstore = TranquilBlockStore::open(BlockStoreConfig {
data_dir: blockstore_data_dir,
index_dir: blockstore_index_dir,
max_file_size: store_cfg.max_blockstore_file_size,
group_commit: Default::default(),
shard_count: tranquil_store::blockstore::DEFAULT_SHARD_COUNT,
})
let blockstore = TranquilBlockStore::open_with_retry(
BlockStoreConfig {
data_dir: blockstore_data_dir,
index_dir: blockstore_index_dir,
max_file_size: store_cfg.max_blockstore_file_size,
group_commit: Default::default(),
shard_count: tranquil_store::blockstore::DEFAULT_SHARD_COUNT,
},
tranquil_store::blockstore::OpenRetryPolicy::default(),
)
.expect("failed to open blockstore");
let event_log = EventLog::open(
@@ -573,6 +573,18 @@ fn wire_tranquil_store(
"repaired orphan data files"
);
}
if repair.orphan_hints_removed > 0 {
tracing::info!(
removed = repair.orphan_hints_removed,
"repaired orphan hint files"
);
}
if repair.phantom_index_entries_purged > 0 {
tracing::info!(
purged = repair.phantom_index_entries_purged,
"purged phantom index entries pointing at missing data files"
);
}
if repair.had_errors() {
tracing::warn!(errors = repair.repair_errors, "some repairs failed");
}
@@ -591,6 +603,17 @@ fn wire_tranquil_store(
}
}
if std::env::var("TRANQUIL_PURGE_ORPHAN_REPOS").is_ok_and(|v| v == "1") {
match metastore
.repo_ops()
.purge_orphan_repos(metastore.database())
{
Ok(0) => tracing::info!("orphan repo purge: no orphans found"),
Ok(n) => tracing::info!(purged = n, "orphan repo purge: removed orphan repo_meta"),
Err(e) => tracing::error!(error = %e, "orphan repo purge failed"),
}
}
let notifier = bridge.notifier();
let signal_db = metastore.database().clone();
let signal_ks = metastore.signal_keyspace();
+212 -5
View File
@@ -7,6 +7,7 @@ use rand::Rng;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::num::NonZeroUsize;
use std::str::FromStr;
use std::sync::OnceLock;
@@ -96,22 +97,99 @@ pub fn generate_random_token() -> String {
URL_SAFE_NO_PAD.encode(bytes)
}
pub fn extract_client_ip(headers: &HeaderMap, addr: Option<SocketAddr>) -> String {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ForwardedTrust {
Peer,
Proxies(NonZeroUsize),
}
fn resolve_trust(configured: Option<usize>, terminates_tls: bool) -> ForwardedTrust {
let count = configured.unwrap_or(if terminates_tls { 0 } else { 1 });
match NonZeroUsize::new(count) {
Some(proxies) => ForwardedTrust::Proxies(proxies),
None => ForwardedTrust::Peer,
}
}
pub(crate) fn forwarded_trust() -> ForwardedTrust {
match tranquil_config::try_get() {
Some(cfg) => resolve_trust(
cfg.server.trusted_proxy_count,
cfg.server.tls.material().is_some(),
),
None => ForwardedTrust::Peer,
}
}
fn forwarded_client_ip(headers: &HeaderMap, trusted: NonZeroUsize) -> Option<String> {
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(value) = forwarded.to_str()
&& let Some(first_ip) = value.split(',').next()
{
return first_ip.trim().to_string();
let hops: Vec<&str> = value
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if let Some(client) = hops
.len()
.checked_sub(trusted.get())
.and_then(|idx| hops.get(idx))
{
return Some((*client).to_string());
}
}
if let Some(real_ip) = headers.get("x-real-ip")
if trusted.get() == 1
&& let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(value) = real_ip.to_str()
&& !value.trim().is_empty()
{
return value.trim().to_string();
return Some(value.trim().to_string());
}
None
}
pub(crate) fn extract_client_ip(
headers: &HeaderMap,
addr: Option<SocketAddr>,
trust: ForwardedTrust,
) -> String {
if let ForwardedTrust::Proxies(trusted) = trust
&& let Some(client) = forwarded_client_ip(headers, trusted)
{
return client;
}
addr.map(|a| a.ip().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
pub(crate) fn client_ip_from_parts(parts: &axum::http::request::Parts) -> String {
let addr = parts
.extensions
.get::<axum::extract::ConnectInfo<SocketAddr>>()
.map(|connect_info| connect_info.0);
extract_client_ip(&parts.headers, addr, forwarded_trust())
}
#[derive(Debug, Clone)]
pub struct ClientIp(String);
impl ClientIp {
pub fn into_string(self) -> String {
self.0
}
}
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for ClientIp {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
Ok(ClientIp(client_ip_from_parts(parts)))
}
}
pub fn set_discord_bot_username(username: String) {
DISCORD_BOT_USERNAME.set(username).ok();
}
@@ -227,6 +305,135 @@ pub fn is_self_hosted_did_web_enabled() -> bool {
#[cfg(test)]
mod tests {
use super::*;
use axum::extract::{ConnectInfo, FromRequestParts};
fn proxies(count: usize) -> ForwardedTrust {
ForwardedTrust::Proxies(NonZeroUsize::new(count).unwrap())
}
#[test]
fn resolve_trust_override_wins_over_tls() {
assert_eq!(resolve_trust(Some(1), true), proxies(1));
assert_eq!(resolve_trust(Some(3), false), proxies(3));
assert_eq!(resolve_trust(Some(0), true), ForwardedTrust::Peer);
assert_eq!(resolve_trust(Some(0), false), ForwardedTrust::Peer);
}
#[test]
fn resolve_trust_infers_from_tls_when_unset() {
assert_eq!(resolve_trust(None, true), ForwardedTrust::Peer);
assert_eq!(resolve_trust(None, false), proxies(1));
}
fn parts_with(
header: Option<(&str, &str)>,
peer: Option<SocketAddr>,
) -> axum::http::request::Parts {
let mut builder = axum::http::Request::builder();
if let Some((name, value)) = header {
builder = builder.header(name, value);
}
let mut parts = builder.body(()).unwrap().into_parts().0;
if let Some(addr) = peer {
parts.extensions.insert(ConnectInfo(addr));
}
parts
}
#[tokio::test]
async fn client_ip_falls_back_to_peer_socket() {
let peer: SocketAddr = "203.0.113.7:51000".parse().unwrap();
let mut parts = parts_with(None, Some(peer));
let ip = ClientIp::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(ip.into_string(), "203.0.113.7");
}
#[tokio::test]
async fn client_ip_ignores_forwarded_when_config_absent() {
let peer: SocketAddr = "203.0.113.7:51000".parse().unwrap();
let mut parts = parts_with(
Some(("x-forwarded-for", "198.51.100.4, 10.0.0.1")),
Some(peer),
);
let ip = ClientIp::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(ip.into_string(), "203.0.113.7");
}
#[tokio::test]
async fn client_ip_unknown_without_headers_or_peer() {
let mut parts = parts_with(None, None);
let ip = ClientIp::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(ip.into_string(), "unknown");
}
#[tokio::test]
async fn client_ip_renders_ipv6_peer_without_brackets() {
let peer: SocketAddr = "[2001:db8::beef]:51000".parse().unwrap();
let mut parts = parts_with(None, Some(peer));
let ip = ClientIp::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(ip.into_string(), "2001:db8::beef");
}
#[test]
fn extract_client_ip_single_proxy_takes_rightmost_forwarded_hop() {
let mut headers = HeaderMap::new();
headers.insert(
"x-forwarded-for",
"9.9.9.9, 198.51.100.4, 10.0.0.1".parse().unwrap(),
);
let peer: SocketAddr = "203.0.113.7:51000".parse().unwrap();
assert_eq!(
extract_client_ip(&headers, Some(peer), proxies(1)),
"10.0.0.1"
);
}
#[test]
fn extract_client_ip_two_proxies_skips_inner_hop() {
let mut headers = HeaderMap::new();
headers.insert(
"x-forwarded-for",
"9.9.9.9, 198.51.100.4, 10.0.0.1".parse().unwrap(),
);
let peer: SocketAddr = "203.0.113.7:51000".parse().unwrap();
assert_eq!(
extract_client_ip(&headers, Some(peer), proxies(2)),
"198.51.100.4"
);
}
#[test]
fn extract_client_ip_more_trusted_proxies_than_hops_uses_peer() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "10.0.0.1".parse().unwrap());
let peer: SocketAddr = "203.0.113.7:51000".parse().unwrap();
assert_eq!(
extract_client_ip(&headers, Some(peer), proxies(2)),
"203.0.113.7"
);
}
#[test]
fn extract_client_ip_ignores_forwarded_headers_for_direct_peer() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "9.9.9.9".parse().unwrap());
headers.insert("x-real-ip", "9.9.9.9".parse().unwrap());
let peer: SocketAddr = "203.0.113.7:51000".parse().unwrap();
assert_eq!(
extract_client_ip(&headers, Some(peer), ForwardedTrust::Peer),
"203.0.113.7"
);
}
#[test]
fn extract_client_ip_direct_peer_without_socket_is_unknown() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "9.9.9.9".parse().unwrap());
assert_eq!(
extract_client_ip(&headers, None, ForwardedTrust::Peer),
"unknown"
);
}
#[test]
fn test_parse_repeated_query_param_repeated() {
@@ -99,9 +99,8 @@ async fn test_check_account_status_returns_correct_block_count() {
after_delete_blocks
);
assert!(
after_delete_blocks >= initial_blocks,
"Block count after delete should be at least initial count (initial {}, now {})",
initial_blocks,
after_delete_blocks >= 2,
"Block count after delete should have at least commit + MST root (got {})",
after_delete_blocks
);
}
-24
View File
@@ -191,16 +191,12 @@ async fn setup_with_external_infra() -> String {
async fn setup_with_testcontainers() -> String {
let temp_dir = std::env::temp_dir().join(format!("tranquil-pds-test-{}", uuid::Uuid::new_v4()));
let blob_path = temp_dir.join("blobs");
let backup_path = temp_dir.join("backups");
std::fs::create_dir_all(&blob_path).expect("Failed to create blob temp directory");
std::fs::create_dir_all(&backup_path).expect("Failed to create backup temp directory");
TEST_TEMP_DIR.set(temp_dir).ok();
let plc_url = setup_mock_plc_directory().await;
unsafe {
std::env::set_var("BLOB_STORAGE_BACKEND", "filesystem");
std::env::set_var("BLOB_STORAGE_PATH", blob_path.to_str().unwrap());
std::env::set_var("BACKUP_STORAGE_BACKEND", "filesystem");
std::env::set_var("BACKUP_STORAGE_PATH", backup_path.to_str().unwrap());
std::env::set_var("MAX_IMPORT_SIZE", "100000000");
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
std::env::set_var("PLC_DIRECTORY_URL", &plc_url);
@@ -242,8 +238,6 @@ async fn setup_with_testcontainers() -> String {
let plc_url = setup_mock_plc_directory().await;
unsafe {
std::env::set_var("BLOB_STORAGE_BACKEND", "s3");
std::env::set_var("BACKUP_STORAGE_BACKEND", "s3");
std::env::set_var("BACKUP_S3_BUCKET", "test-backups");
std::env::set_var("S3_BUCKET", "test-bucket");
std::env::set_var("AWS_ACCESS_KEY_ID", "minioadmin");
std::env::set_var("AWS_SECRET_ACCESS_KEY", "minioadmin");
@@ -333,8 +327,6 @@ unsafe fn configure_external_storage_env() {
if std::env::var("S3_ENDPOINT").is_ok() {
let s3_endpoint = std::env::var("S3_ENDPOINT").unwrap();
std::env::set_var("BLOB_STORAGE_BACKEND", "s3");
std::env::set_var("BACKUP_STORAGE_BACKEND", "s3");
std::env::set_var("BACKUP_S3_BUCKET", "test-backups");
std::env::set_var(
"S3_BUCKET",
std::env::var("S3_BUCKET").unwrap_or_else(|_| "test-bucket".to_string()),
@@ -356,14 +348,10 @@ unsafe fn configure_external_storage_env() {
let process_dir =
std::env::temp_dir().join(format!("tranquil-pds-test-{}", std::process::id()));
let blob_path = process_dir.join("blobs");
let backup_path = process_dir.join("backups");
std::fs::create_dir_all(&blob_path).expect("Failed to create blob directory");
std::fs::create_dir_all(&backup_path).expect("Failed to create backup directory");
TEST_TEMP_DIR.set(process_dir).ok();
std::env::set_var("BLOB_STORAGE_BACKEND", "filesystem");
std::env::set_var("BLOB_STORAGE_PATH", blob_path.to_str().unwrap());
std::env::set_var("BACKUP_STORAGE_BACKEND", "filesystem");
std::env::set_var("BACKUP_STORAGE_PATH", backup_path.to_str().unwrap());
}
std::env::set_var("MAX_IMPORT_SIZE", "100000000");
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
@@ -622,18 +610,14 @@ async fn setup_store_backend() -> String {
let temp_dir =
std::env::temp_dir().join(format!("tranquil-pds-store-{}", uuid::Uuid::new_v4()));
let blob_path = temp_dir.join("blobs");
let backup_path = temp_dir.join("backups");
let store_path = temp_dir.join("store");
std::fs::create_dir_all(&blob_path).expect("failed to create blob temp directory");
std::fs::create_dir_all(&backup_path).expect("failed to create backup temp directory");
std::fs::create_dir_all(&store_path).expect("failed to create store temp directory");
TEST_TEMP_DIR.set(temp_dir).ok();
let plc_url = setup_mock_plc_directory().await;
unsafe {
std::env::set_var("BLOB_STORAGE_BACKEND", "filesystem");
std::env::set_var("BLOB_STORAGE_PATH", blob_path.to_str().unwrap());
std::env::set_var("BACKUP_STORAGE_BACKEND", "filesystem");
std::env::set_var("BACKUP_STORAGE_PATH", backup_path.to_str().unwrap());
std::env::set_var("MAX_IMPORT_SIZE", "100000000");
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
std::env::set_var("PLC_DIRECTORY_URL", &plc_url);
@@ -790,18 +774,14 @@ async fn setup_cluster_store_backend() -> Option<sqlx::PgPool> {
uuid::Uuid::new_v4()
));
let blob_path = temp_dir.join("blobs");
let backup_path = temp_dir.join("backups");
let store_path = temp_dir.join("store");
std::fs::create_dir_all(&blob_path).expect("failed to create blob temp directory");
std::fs::create_dir_all(&backup_path).expect("failed to create backup temp directory");
std::fs::create_dir_all(&store_path).expect("failed to create store temp directory");
TEST_TEMP_DIR.set(temp_dir).ok();
let plc_url = setup_mock_plc_directory().await;
unsafe {
std::env::set_var("BLOB_STORAGE_BACKEND", "filesystem");
std::env::set_var("BLOB_STORAGE_PATH", blob_path.to_str().unwrap());
std::env::set_var("BACKUP_STORAGE_BACKEND", "filesystem");
std::env::set_var("BACKUP_STORAGE_PATH", backup_path.to_str().unwrap());
std::env::set_var("MAX_IMPORT_SIZE", "100000000");
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
std::env::set_var("PLC_DIRECTORY_URL", &plc_url);
@@ -847,16 +827,12 @@ async fn setup_cluster_testcontainers() -> Option<sqlx::PgPool> {
let temp_dir =
std::env::temp_dir().join(format!("tranquil-pds-cluster-{}", uuid::Uuid::new_v4()));
let blob_path = temp_dir.join("blobs");
let backup_path = temp_dir.join("backups");
std::fs::create_dir_all(&blob_path).expect("Failed to create blob temp directory");
std::fs::create_dir_all(&backup_path).expect("Failed to create backup temp directory");
TEST_TEMP_DIR.set(temp_dir).ok();
let plc_url = setup_mock_plc_directory().await;
unsafe {
std::env::set_var("BLOB_STORAGE_BACKEND", "filesystem");
std::env::set_var("BLOB_STORAGE_PATH", blob_path.to_str().unwrap());
std::env::set_var("BACKUP_STORAGE_BACKEND", "filesystem");
std::env::set_var("BACKUP_STORAGE_PATH", backup_path.to_str().unwrap());
std::env::set_var("MAX_IMPORT_SIZE", "100000000");
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
std::env::set_var("PLC_DIRECTORY_URL", &plc_url);
@@ -0,0 +1,169 @@
mod common;
use chrono::Utc;
use common::*;
use reqwest::StatusCode;
use serde_json::{Value, json};
fn run_compaction(store: &tranquil_store::blockstore::TranquilBlockStore) {
let liveness = store.compaction_liveness(0).unwrap();
liveness
.iter()
.filter(|(_, info)| info.total_blocks > 0 && info.ratio() < 0.95)
.map(|(&fid, _)| fid)
.collect::<Vec<_>>()
.into_iter()
.for_each(|fid| match store.compact_file(fid, 0) {
Ok(_) => {}
Err(tranquil_store::blockstore::CompactionError::ActiveFileCannotBeCompacted) => {}
Err(e) => eprintln!("compaction: {e}"),
});
}
#[tokio::test]
async fn mst_blocks_survive_full_store_reopen() {
if !is_store_backend() {
eprintln!("skipping: only meaningful with tranquil-store backend");
return;
}
let client = client();
let base = base_url().await;
let block_store = get_test_block_store().await;
let store = block_store
.as_tranquil_store()
.expect("expected tranquil-store backend");
let (jwt, did) = create_account_and_login(&client).await;
let mut posts = Vec::new();
for i in 0..30 {
let res = client
.post(format!("{base}/xrpc/com.atproto.repo.createRecord"))
.bearer_auth(&jwt)
.json(&json!({
"repo": did,
"collection": "app.bsky.feed.post",
"record": {
"$type": "app.bsky.feed.post",
"text": format!("compaction test post {i}"),
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
posts.push((
body["uri"].as_str().unwrap().to_string(),
body["cid"].as_str().unwrap().to_string(),
));
}
for (uri, cid) in &posts[..20] {
let res = client
.post(format!("{base}/xrpc/com.atproto.repo.createRecord"))
.bearer_auth(&jwt)
.json(&json!({
"repo": did,
"collection": "app.bsky.feed.like",
"record": {
"$type": "app.bsky.feed.like",
"subject": { "uri": uri, "cid": cid },
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK, "like failed for {uri}");
}
let data_dir = store.data_dir().to_path_buf();
let index_dir = data_dir.parent().unwrap().join("index");
let store_clone = store.clone();
tokio::task::spawn_blocking(move || {
(0..40).for_each(|_| run_compaction(&store_clone));
})
.await
.unwrap();
let repo_root_str: String = get_test_repos()
.await
.repo
.get_repo_root_by_did(&tranquil_types::Did::new(did.clone()).unwrap())
.await
.expect("db error")
.expect("no repo root")
.to_string();
let head_cid = cid::Cid::try_from(repo_root_str.as_str()).expect("invalid cid");
let car_blocks = tranquil_pds::scheduled::collect_current_repo_blocks(block_store, &head_cid)
.await
.expect("collect blocks");
let block_count_before = car_blocks.len();
let max_file_size = store
.list_data_files()
.ok()
.map(|_| 4 * 1024 * 1024u64)
.unwrap_or(4 * 1024 * 1024);
let reopened_missing = tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Handle::current();
let _guard = rt.enter();
let config = tranquil_store::blockstore::BlockStoreConfig {
data_dir: data_dir.clone(),
index_dir,
max_file_size,
group_commit: tranquil_store::blockstore::GroupCommitConfig::default(),
shard_count: 1,
};
let fresh =
tranquil_store::blockstore::TranquilBlockStore::open(config).expect("reopen failed");
let missing: Vec<String> = car_blocks
.iter()
.filter_map(|cid_bytes| {
if cid_bytes.len() < 36 {
return None;
}
let mut arr = [0u8; 36];
arr.copy_from_slice(&cid_bytes[..36]);
match fresh.get_block_sync(&arr) {
Ok(Some(_)) => None,
Ok(None) => Some(format!(
"missing {}",
cid::Cid::try_from(cid_bytes.as_slice())
.map(|c| c.to_string())
.unwrap_or_else(|_| hex::encode(cid_bytes))
)),
Err(e) => Some(format!("error: {e}")),
}
})
.collect();
drop(fresh);
missing
})
.await
.unwrap();
assert!(
reopened_missing.is_empty(),
"{} of {block_count_before} blocks missing after blockstore reopen:\n{}",
reopened_missing.len(),
reopened_missing
.iter()
.take(20)
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join("\n"),
);
}
@@ -8,7 +8,6 @@ const HANDLE_DOMAIN: &str = "handles.test";
fn set_handle_domain() {
unsafe {
std::env::set_var("AVAILABLE_USER_DOMAINS", HANDLE_DOMAIN);
std::env::set_var("PDS_USER_HANDLE_DOMAINS", HANDLE_DOMAIN);
}
}
@@ -456,7 +456,7 @@ async fn test_apply_writes_batch() {
"writes": [
{ "$type": "com.atproto.repo.applyWrites#create", "collection": "app.bsky.feed.post", "rkey": "batch-post-1", "value": { "$type": "app.bsky.feed.post", "text": "First batch post", "createdAt": now } },
{ "$type": "com.atproto.repo.applyWrites#create", "collection": "app.bsky.feed.post", "rkey": "batch-post-2", "value": { "$type": "app.bsky.feed.post", "text": "Second batch post", "createdAt": now } },
{ "$type": "com.atproto.repo.applyWrites#create", "collection": "app.bsky.actor.profile", "rkey": "self", "value": { "$type": "app.bsky.actor.profile", "displayName": "Batch User" } }
{ "$type": "com.atproto.repo.applyWrites#update", "collection": "app.bsky.actor.profile", "rkey": "self", "value": { "$type": "app.bsky.actor.profile", "displayName": "Batch User" } }
]
});
let apply_res = client
@@ -597,3 +597,157 @@ async fn test_request_account_delete() {
"Token should not be expired"
);
}
async fn create_app_password_session(
client: &reqwest::Client,
did: &str,
main_jwt: &str,
name: &str,
body: Value,
) -> (String, Value) {
let base = base_url().await;
let create_res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAppPassword",
base
))
.bearer_auth(main_jwt)
.json(&body)
.send()
.await
.expect("Failed to create app password");
assert_eq!(create_res.status(), StatusCode::OK);
let app_pass: Value = create_res.json().await.unwrap();
let password = app_pass["password"].as_str().unwrap().to_string();
let scopes_response = app_pass.clone();
let login_res = client
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
.json(&json!({ "identifier": did, "password": password }))
.send()
.await
.expect("Failed to login with app password");
assert_eq!(
login_res.status(),
StatusCode::OK,
"App password login for '{}' failed",
name
);
let session: Value = login_res.json().await.unwrap();
let jwt = session["accessJwt"].as_str().unwrap().to_string();
(jwt, scopes_response)
}
async fn try_chat_service_auth(client: &reqwest::Client, jwt: &str) -> StatusCode {
let base = base_url().await;
let res = client
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", base))
.bearer_auth(jwt)
.query(&[
("aud", "did:web:api.bsky.app"),
("lxm", "chat.bsky.convo.listConvos"),
])
.send()
.await
.expect("Failed to call getServiceAuth");
res.status()
}
#[tokio::test]
async fn test_app_password_non_privileged_blocks_chat() {
let client = client();
let (did, jwt) = setup_new_user("appscope-nonchat").await;
let (app_jwt, create_body) = create_app_password_session(
&client,
&did,
&jwt,
"non-privileged",
json!({ "name": "NoChatApp", "privileged": false }),
)
.await;
assert_eq!(
create_body["scopes"].as_str().unwrap(),
"transition:generic",
"Non-privileged app password should not have chat scope"
);
let status = try_chat_service_auth(&client, &app_jwt).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"Non-privileged app password must not access chat methods"
);
}
#[tokio::test]
async fn test_app_password_privileged_allows_chat() {
let client = client();
let (did, jwt) = setup_new_user("appscope-chat").await;
let (app_jwt, create_body) = create_app_password_session(
&client,
&did,
&jwt,
"privileged",
json!({ "name": "ChatApp", "privileged": true }),
)
.await;
assert_eq!(
create_body["scopes"].as_str().unwrap(),
"transition:generic transition:chat.bsky",
"Privileged app password should have chat scope"
);
let status = try_chat_service_auth(&client, &app_jwt).await;
assert_eq!(
status,
StatusCode::OK,
"Privileged app password should access chat methods"
);
}
#[tokio::test]
async fn test_app_password_no_privileged_field_allows_chat() {
let client = client();
let (did, jwt) = setup_new_user("appscope-full").await;
let (app_jwt, create_body) = create_app_password_session(
&client,
&did,
&jwt,
"full-access",
json!({ "name": "FullApp" }),
)
.await;
assert_eq!(
create_body["scopes"].as_str().unwrap(),
"transition:generic transition:chat.bsky",
"App password without privileged field should default to full access"
);
let status = try_chat_service_auth(&client, &app_jwt).await;
assert_eq!(
status,
StatusCode::OK,
"Full-access app password should access chat methods"
);
}
#[tokio::test]
async fn test_app_password_explicit_scopes_respected() {
let client = client();
let (did, jwt) = setup_new_user("appscope-explicit").await;
let (app_jwt, create_body) = create_app_password_session(
&client,
&did,
&jwt,
"explicit-scopes",
json!({ "name": "ScopedApp", "scopes": "transition:generic" }),
)
.await;
assert_eq!(
create_body["scopes"].as_str().unwrap(),
"transition:generic",
"Explicit scopes should be stored as-is"
);
let status = try_chat_service_auth(&client, &app_jwt).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"App password with only transition:generic should not access chat"
);
}
@@ -0,0 +1,583 @@
use std::collections::BTreeSet;
use std::sync::Arc;
use cid::Cid;
use jacquard_repo::mst::Mst;
use jacquard_repo::storage::MemoryBlockStore;
fn test_cid(n: u32) -> Cid {
let data = n.to_be_bytes();
let mut buf = [0u8; 32];
buf[..4].copy_from_slice(&data);
buf[4] = (n >> 8) as u8 ^ 0xAB;
buf[5] = (n & 0xFF) as u8 ^ 0xCD;
let mh = multihash::Multihash::wrap(0x12, &buf).unwrap();
Cid::new_v1(0x71, mh)
}
async fn compute_obsolete_full_walk<
S: jacquard_repo::storage::BlockStore + Sync + Send + 'static,
>(
old: &Mst<S>,
new: &Mst<S>,
) -> BTreeSet<Cid> {
let old_nodes = old.collect_node_cids().await.unwrap();
let new_nodes = new.collect_node_cids().await.unwrap();
let old_leaves = old.leaves().await.unwrap();
let new_leaves = new.leaves().await.unwrap();
let old_nodes_set: BTreeSet<Cid> = old_nodes.into_iter().collect();
let new_nodes_set: BTreeSet<Cid> = new_nodes.into_iter().collect();
let old_leaf_set: BTreeSet<Cid> = old_leaves.iter().map(|(_, cid)| *cid).collect();
let new_leaf_set: BTreeSet<Cid> = new_leaves.iter().map(|(_, cid)| *cid).collect();
old_nodes_set
.difference(&new_nodes_set)
.copied()
.chain(old_leaf_set.difference(&new_leaf_set).copied())
.collect()
}
fn compute_obsolete_from_diff(diff: &jacquard_repo::mst::diff::MstDiff) -> BTreeSet<Cid> {
diff.removed_mst_blocks
.iter()
.copied()
.chain(diff.removed_cids.iter().copied())
.collect()
}
async fn assert_equivalence(
old_records: &[(String, u32)],
new_records: &[(String, u32)],
scenario: &str,
) {
let storage = Arc::new(MemoryBlockStore::new());
let mut old_tree = Mst::new(storage.clone());
for (key, val) in old_records {
old_tree = old_tree.add(key, test_cid(*val)).await.unwrap();
}
let old_root = old_tree.persist().await.unwrap();
let mut new_tree = Mst::new(storage.clone());
for (key, val) in new_records {
new_tree = new_tree.add(key, test_cid(*val)).await.unwrap();
}
let new_root = new_tree.persist().await.unwrap();
let old_settled = Mst::load(storage.clone(), old_root, None);
let new_settled = Mst::load(storage.clone(), new_root, None);
let full_walk_obsolete = compute_obsolete_full_walk(&old_settled, &new_settled).await;
let old_for_diff = Mst::load(storage.clone(), old_root, None);
let new_for_diff = Mst::load(storage, new_root, None);
let diff = old_for_diff.diff(&new_for_diff).await.unwrap();
let diff_obsolete = compute_obsolete_from_diff(&diff);
assert_eq!(
full_walk_obsolete,
diff_obsolete,
"MISMATCH in scenario: {scenario}\n full_walk count: {}\n diff count: {}\n in full_walk but not diff: {:?}\n in diff but not full_walk: {:?}",
full_walk_obsolete.len(),
diff_obsolete.len(),
full_walk_obsolete
.difference(&diff_obsolete)
.collect::<Vec<_>>(),
diff_obsolete
.difference(&full_walk_obsolete)
.collect::<Vec<_>>(),
);
}
fn make_key(collection: &str, i: u32) -> String {
format!("{collection}/{i:06}")
}
fn generate_records(collection: &str, range: std::ops::Range<u32>) -> Vec<(String, u32)> {
range.map(|i| (make_key(collection, i), i)).collect()
}
fn generate_multi_collection_records(
collections: &[&str],
per_collection: u32,
) -> Vec<(String, u32)> {
collections
.iter()
.enumerate()
.flat_map(|(ci, coll)| {
let base = ci as u32 * per_collection;
(0..per_collection).map(move |i| (make_key(coll, i), base + i))
})
.collect()
}
fn apply_scattered_updates(
records: &[(String, u32)],
stride: usize,
cid_offset: u32,
) -> Vec<(String, u32)> {
records
.iter()
.enumerate()
.map(|(idx, (key, val))| {
if idx % stride == 0 {
(key.clone(), val + cid_offset)
} else {
(key.clone(), *val)
}
})
.collect()
}
fn remove_every_nth(records: &[(String, u32)], n: usize) -> Vec<(String, u32)> {
records
.iter()
.enumerate()
.filter(|(idx, _)| idx % n != 0)
.map(|(_, r)| r.clone())
.collect()
}
fn remove_range(records: &[(String, u32)], start: usize, count: usize) -> Vec<(String, u32)> {
records
.iter()
.enumerate()
.filter(|(idx, _)| *idx < start || *idx >= start + count)
.map(|(_, r)| r.clone())
.collect()
}
fn keep_only_collection(records: &[(String, u32)], collection: &str) -> Vec<(String, u32)> {
records
.iter()
.filter(|(key, _)| key.starts_with(collection))
.cloned()
.collect()
}
fn append_records(
base: &[(String, u32)],
collection: &str,
range: std::ops::Range<u32>,
cid_base: u32,
) -> Vec<(String, u32)> {
let mut result = base.to_vec();
result.extend(range.map(|i| (make_key(collection, i), cid_base + i)));
result.sort_by(|(a, _), (b, _)| a.cmp(b));
result
}
#[tokio::test]
async fn massive_tree_single_create() {
let old = generate_records("app.bsky.feed.post", 0..2000);
let new_rec = append_records(&old, "app.bsky.feed.post", 2000..2001, 2000);
assert_equivalence(&old, &new_rec, "2000 records + 1 create").await;
}
#[tokio::test]
async fn massive_tree_single_delete() {
let old = generate_records("app.bsky.feed.post", 0..2000);
let new_rec = remove_range(&old, 1000, 1);
assert_equivalence(&old, &new_rec, "2000 records - 1 delete from middle").await;
}
#[tokio::test]
async fn massive_tree_single_update() {
let old = generate_records("app.bsky.feed.post", 0..2000);
let new_rec: Vec<_> = old
.iter()
.map(|(k, v)| {
if k == "app.bsky.feed.post/001000" {
(k.clone(), v + 50000)
} else {
(k.clone(), *v)
}
})
.collect();
assert_equivalence(&old, &new_rec, "2000 records - 1 update in middle").await;
}
#[tokio::test]
async fn massive_tree_scattered_updates_every_3rd() {
let old = generate_records("app.bsky.feed.post", 0..1500);
let new_rec = apply_scattered_updates(&old, 3, 10000);
assert_equivalence(&old, &new_rec, "1500 records - update every 3rd").await;
}
#[tokio::test]
async fn massive_tree_scattered_updates_every_7th() {
let old = generate_records("app.bsky.feed.post", 0..2000);
let new_rec = apply_scattered_updates(&old, 7, 20000);
assert_equivalence(&old, &new_rec, "2000 records - update every 7th").await;
}
#[tokio::test]
async fn massive_tree_delete_every_2nd() {
let old = generate_records("app.bsky.feed.post", 0..1000);
let new_rec = remove_every_nth(&old, 2);
assert_equivalence(&old, &new_rec, "1000 records - delete every 2nd").await;
}
#[tokio::test]
async fn massive_tree_delete_every_5th() {
let old = generate_records("app.bsky.feed.post", 0..2000);
let new_rec = remove_every_nth(&old, 5);
assert_equivalence(&old, &new_rec, "2000 records - delete every 5th").await;
}
#[tokio::test]
async fn massive_tree_delete_first_half() {
let old = generate_records("app.bsky.feed.post", 0..1500);
let new_rec = remove_range(&old, 0, 750);
assert_equivalence(&old, &new_rec, "1500 records - delete first 750").await;
}
#[tokio::test]
async fn massive_tree_delete_last_half() {
let old = generate_records("app.bsky.feed.post", 0..1500);
let new_rec = remove_range(&old, 750, 750);
assert_equivalence(&old, &new_rec, "1500 records - delete last 750").await;
}
#[tokio::test]
async fn massive_tree_delete_middle_chunk() {
let old = generate_records("app.bsky.feed.post", 0..2000);
let new_rec = remove_range(&old, 800, 400);
assert_equivalence(&old, &new_rec, "2000 records - delete 400 from middle").await;
}
#[tokio::test]
async fn empty_to_massive() {
let new_rec = generate_records("app.bsky.feed.post", 0..1500);
assert_equivalence(&[], &new_rec, "empty to 1500 records").await;
}
#[tokio::test]
async fn massive_to_empty() {
let old = generate_records("app.bsky.feed.post", 0..1500);
assert_equivalence(&old, &[], "1500 records to empty").await;
}
#[tokio::test]
async fn massive_complete_replacement() {
let old = generate_records("app.bsky.feed.post", 0..1000);
let new_rec = generate_records("app.bsky.feed.post", 1000..2000);
assert_equivalence(
&old,
&new_rec,
"1000 records fully replaced with 1000 different",
)
.await;
}
#[tokio::test]
async fn massive_no_change() {
let records = generate_records("app.bsky.feed.post", 0..1500);
assert_equivalence(&records, &records, "1500 records unchanged").await;
}
#[tokio::test]
async fn multi_collection_5_collections_500_each() {
let collections = [
"app.bsky.feed.like",
"app.bsky.feed.post",
"app.bsky.feed.repost",
"app.bsky.graph.follow",
"app.bsky.graph.block",
];
let old = generate_multi_collection_records(&collections, 500);
let new_rec = apply_scattered_updates(&old, 4, 30000);
assert_equivalence(
&old,
&new_rec,
"5 collections x 500 records - update every 4th",
)
.await;
}
#[tokio::test]
async fn multi_collection_wipe_one_collection() {
let collections = [
"app.bsky.feed.like",
"app.bsky.feed.post",
"app.bsky.feed.repost",
"app.bsky.graph.follow",
];
let old = generate_multi_collection_records(&collections, 400);
let new_rec: Vec<_> = old
.iter()
.filter(|(key, _)| !key.starts_with("app.bsky.feed.repost"))
.cloned()
.collect();
assert_equivalence(
&old,
&new_rec,
"4 collections x 400 - wipe repost collection",
)
.await;
}
#[tokio::test]
async fn multi_collection_keep_only_one() {
let collections = [
"app.bsky.feed.like",
"app.bsky.feed.post",
"app.bsky.feed.repost",
"app.bsky.graph.follow",
"app.bsky.graph.block",
];
let old = generate_multi_collection_records(&collections, 300);
let new_rec = keep_only_collection(&old, "app.bsky.feed.post");
assert_equivalence(&old, &new_rec, "5 collections x 300 - keep only posts").await;
}
#[tokio::test]
async fn multi_collection_add_new_collection() {
let old_collections = ["app.bsky.feed.like", "app.bsky.feed.post"];
let old = generate_multi_collection_records(&old_collections, 500);
let new_rec = append_records(&old, "app.bsky.graph.follow", 0..500, 40000);
assert_equivalence(&old, &new_rec, "2 collections x 500 + add 500 follows").await;
}
#[tokio::test]
async fn mixed_ops_massive_tree() {
let collections = [
"app.bsky.feed.like",
"app.bsky.feed.post",
"app.bsky.feed.repost",
"app.bsky.graph.follow",
];
let old = generate_multi_collection_records(&collections, 400);
let mut new_rec: Vec<_> = old
.iter()
.filter(|(key, _)| !key.starts_with("app.bsky.feed.repost"))
.enumerate()
.map(|(idx, (key, val))| {
if key.starts_with("app.bsky.feed.like") && idx % 3 == 0 {
(key.clone(), val + 50000)
} else {
(key.clone(), *val)
}
})
.collect();
new_rec.extend((0..200u32).map(|i| (make_key("app.bsky.graph.block", i), 60000 + i)));
new_rec.sort_by(|(a, _), (b, _)| a.cmp(b));
assert_equivalence(
&old,
&new_rec,
"4 collections x 400: wipe reposts, update every 3rd like, add 200 blocks",
)
.await;
}
#[tokio::test]
async fn grow_tree_by_double() {
let old = generate_records("app.bsky.feed.post", 0..1000);
let new_rec = generate_records("app.bsky.feed.post", 0..2000);
assert_equivalence(&old, &new_rec, "grow from 1000 to 2000").await;
}
#[tokio::test]
async fn shrink_tree_by_half() {
let old = generate_records("app.bsky.feed.post", 0..2000);
let new_rec = generate_records("app.bsky.feed.post", 0..1000);
assert_equivalence(&old, &new_rec, "shrink from 2000 to 1000").await;
}
#[tokio::test]
async fn interleaved_keys_disjoint_ranges() {
let old: Vec<_> = (0..1000u32)
.map(|i| (make_key("app.bsky.feed.post", i * 2), i))
.collect();
let new_rec: Vec<_> = (0..1000u32)
.map(|i| (make_key("app.bsky.feed.post", i * 2 + 1), i + 10000))
.collect();
assert_equivalence(
&old,
&new_rec,
"1000 even-keyed records replaced by 1000 odd-keyed",
)
.await;
}
#[tokio::test]
async fn sparse_keys_wide_gaps() {
let old: Vec<_> = (0..500u32)
.map(|i| (make_key("app.bsky.feed.post", i * 100), i))
.collect();
let new_rec: Vec<_> = (0..500u32)
.map(|i| {
if i % 10 == 0 {
(make_key("app.bsky.feed.post", i * 100), i + 70000)
} else {
(make_key("app.bsky.feed.post", i * 100), i)
}
})
.collect();
assert_equivalence(&old, &new_rec, "500 sparse keys - update every 10th").await;
}
#[tokio::test]
async fn many_collections_few_records_each() {
let collections: Vec<String> = (0..50u32)
.map(|i| format!("com.example.lexicon{i:02}.record"))
.collect();
let old: Vec<_> = collections
.iter()
.enumerate()
.flat_map(|(ci, coll)| {
let base = ci as u32 * 20;
(0..20u32).map(move |i| (make_key(coll, i), base + i))
})
.collect();
let new_rec: Vec<_> = old
.iter()
.enumerate()
.filter_map(|(idx, (key, val))| {
if idx % 15 == 0 {
None
} else if idx % 7 == 0 {
Some((key.clone(), val + 80000))
} else {
Some((key.clone(), *val))
}
})
.collect();
assert_equivalence(
&old,
&new_rec,
"50 collections x 20 records - delete every 15th, update every 7th",
)
.await;
}
#[tokio::test]
async fn update_all_records() {
let old = generate_records("app.bsky.feed.post", 0..1000);
let new_rec: Vec<_> = old
.iter()
.map(|(key, val)| (key.clone(), val + 90000))
.collect();
assert_equivalence(&old, &new_rec, "1000 records - update every single one").await;
}
#[tokio::test]
async fn delete_all_but_one() {
let old = generate_records("app.bsky.feed.post", 0..1500);
let new_rec = vec![old[750].clone()];
assert_equivalence(&old, &new_rec, "1500 records - delete all but middle one").await;
}
#[tokio::test]
async fn one_to_massive() {
let old = vec![(make_key("app.bsky.feed.post", 500), 500u32)];
let new_rec = generate_records("app.bsky.feed.post", 0..1500);
assert_equivalence(&old, &new_rec, "1 record to 1500 records").await;
}
#[tokio::test]
async fn delete_head_and_tail() {
let old = generate_records("app.bsky.feed.post", 0..2000);
let new_rec: Vec<_> = old[200..1800].to_vec();
assert_equivalence(
&old,
&new_rec,
"2000 records - delete first 200 and last 200",
)
.await;
}
#[tokio::test]
async fn keep_head_and_tail_only() {
let old = generate_records("app.bsky.feed.post", 0..2000);
let mut new_rec: Vec<_> = old[..100].to_vec();
new_rec.extend_from_slice(&old[1900..]);
assert_equivalence(
&old,
&new_rec,
"2000 records - keep only first 100 and last 100",
)
.await;
}
#[tokio::test]
async fn massive_tree_update_first_and_last() {
let old = generate_records("app.bsky.feed.post", 0..2000);
let mut new_rec = old.clone();
new_rec[0].1 += 99000;
new_rec[1999].1 += 99000;
assert_equivalence(&old, &new_rec, "2000 records - update only first and last").await;
}
#[tokio::test]
async fn overlapping_collection_swap() {
let old_collections = [
"app.bsky.feed.like",
"app.bsky.feed.post",
"app.bsky.feed.repost",
];
let old = generate_multi_collection_records(&old_collections, 500);
let mut new_rec: Vec<_> = old
.iter()
.filter(|(key, _)| key.starts_with("app.bsky.feed.post"))
.cloned()
.collect();
new_rec.extend((0..500u32).map(|i| (make_key("app.bsky.graph.follow", i), 70000 + i)));
new_rec.extend((0..500u32).map(|i| (make_key("app.bsky.graph.block", i), 71000 + i)));
new_rec.sort_by(|(a, _), (b, _)| a.cmp(b));
assert_equivalence(
&old,
&new_rec,
"swap 2 of 3 collections, keep 1 (posts), 500 each",
)
.await;
}
#[tokio::test]
async fn swiss_cheese_deletions() {
let old = generate_records("app.bsky.feed.post", 0..1500);
let new_rec: Vec<_> = old
.iter()
.enumerate()
.filter(|(idx, _)| {
let bucket = idx / 50;
bucket % 3 != 0
})
.map(|(_, r)| r.clone())
.collect();
assert_equivalence(
&old,
&new_rec,
"1500 records - delete every 3rd chunk of 50",
)
.await;
}
#[tokio::test]
async fn mixed_ops_with_key_density_change() {
let old: Vec<_> = (0..1000u32)
.map(|i| (make_key("app.bsky.feed.post", i * 3), i))
.collect();
let mut new_rec: Vec<_> = old
.iter()
.filter(|(_, val)| val % 4 != 0)
.cloned()
.collect();
new_rec.extend((0..500u32).map(|i| (make_key("app.bsky.feed.post", i * 3 + 1), i + 100000)));
new_rec.sort_by(|(a, _), (b, _)| a.cmp(b));
assert_equivalence(
&old,
&new_rec,
"1000 sparse records: delete every 4th, insert 500 in gaps",
)
.await;
}
@@ -0,0 +1,475 @@
mod common;
mod firehose;
use std::collections::BTreeMap;
use std::io::Cursor;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use cid::Cid;
use common::*;
use firehose::{FirehoseConsumer, ParsedCommitFrame};
use iroh_car::CarReader;
use jacquard_common::smol_str::SmolStr;
use jacquard_repo::commit::Commit;
use jacquard_repo::mst::{Mst, VerifiedWriteOp};
use jacquard_repo::storage::{BlockStore, MemoryBlockStore};
use reqwest::StatusCode;
use serde_json::{Value, json};
use tranquil_scopes::RepoAction;
async fn car_to_blocks(car_bytes: &[u8]) -> BTreeMap<Cid, Bytes> {
let mut reader = CarReader::new(Cursor::new(car_bytes))
.await
.expect("parse CAR header");
let mut blocks = BTreeMap::new();
while let Ok(Some((cid, data))) = reader.next_block().await {
blocks.insert(cid, Bytes::from(data));
}
blocks
}
fn op_to_verified(op: &firehose::ParsedRepoOp) -> Result<VerifiedWriteOp, String> {
let key = SmolStr::new(&op.path);
match op.action {
RepoAction::Create => {
let cid = op.cid.ok_or("create op missing cid")?;
Ok(VerifiedWriteOp::Create { key, cid })
}
RepoAction::Update => {
let cid = op.cid.ok_or("update op missing cid")?;
let prev = op.prev.ok_or("update op missing prev")?;
Ok(VerifiedWriteOp::Update { key, cid, prev })
}
RepoAction::Delete => {
let prev = op.prev.ok_or("delete op missing prev")?;
Ok(VerifiedWriteOp::Delete { key, prev })
}
}
}
async fn verify_frame_forward(frame: &ParsedCommitFrame) -> Result<(), String> {
let prev_data = frame
.prev_data
.ok_or_else(|| "frame missing prev_data (v1.1 required)".to_string())?;
let blocks = car_to_blocks(&frame.blocks).await;
let storage = Arc::new(MemoryBlockStore::new_from_blocks(blocks));
let commit_bytes = storage
.get(&frame.commit)
.await
.map_err(|e| format!("get commit: {e:?}"))?
.ok_or_else(|| format!("CAR missing commit {}", frame.commit))?;
let commit = Commit::from_cbor(&commit_bytes).map_err(|e| format!("parse commit: {e:?}"))?;
let expected = *commit.data();
let mut mst = Mst::load(storage, prev_data, None);
for op in &frame.ops {
let path = &op.path;
match op.action {
RepoAction::Create | RepoAction::Update => {
let cid = op.cid.ok_or_else(|| format!("{path}: op missing cid"))?;
mst = mst
.add(path, cid)
.await
.map_err(|e| format!("forward {path}: {e:?}"))?;
}
RepoAction::Delete => {
mst = mst
.delete(path)
.await
.map_err(|e| format!("forward delete {path}: {e:?}"))?;
}
}
}
let computed = mst.persist().await.map_err(|e| format!("persist: {e:?}"))?;
if computed != expected {
return Err(format!(
"root mismatch expected={expected} computed={computed}"
));
}
Ok(())
}
async fn verify_frame_inverse(frame: &ParsedCommitFrame) -> Result<(), String> {
let prev_data = frame
.prev_data
.ok_or_else(|| "frame missing prev_data (v1.1 required)".to_string())?;
let blocks = car_to_blocks(&frame.blocks).await;
let storage = Arc::new(MemoryBlockStore::new_from_blocks(blocks));
let commit_bytes = storage
.get(&frame.commit)
.await
.map_err(|e| format!("get commit: {e:?}"))?
.ok_or_else(|| format!("CAR missing commit {}", frame.commit))?;
let commit = Commit::from_cbor(&commit_bytes).map_err(|e| format!("parse commit: {e:?}"))?;
let new_data = *commit.data();
let mut mst = Mst::load(storage, new_data, None);
for op in &frame.ops {
let verified = op_to_verified(op)?;
let inverted = mst
.invert_op(verified.clone())
.await
.map_err(|e| format!("invert {verified:?}: {e:?}"))?;
if !inverted {
return Err(format!("op not invertible: {verified:?}"));
}
}
let computed_prev = mst
.get_pointer()
.await
.map_err(|e| format!("get_pointer: {e:?}"))?;
if computed_prev != prev_data {
return Err(format!(
"inverse root mismatch expected={prev_data} computed={computed_prev}"
));
}
Ok(())
}
async fn create_record(client: &reqwest::Client, token: &str, did: &str, rkey: &str, text: &str) {
let now = chrono::Utc::now().to_rfc3339();
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.bearer_auth(token)
.json(&json!({
"repo": did,
"collection": "app.bsky.feed.post",
"rkey": rkey,
"record": {
"$type": "app.bsky.feed.post",
"text": text,
"createdAt": now,
}
}))
.send()
.await
.expect("createRecord");
assert_eq!(res.status(), StatusCode::OK);
}
async fn put_record(client: &reqwest::Client, token: &str, did: &str, rkey: &str, text: &str) {
let now = chrono::Utc::now().to_rfc3339();
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(token)
.json(&json!({
"repo": did,
"collection": "app.bsky.feed.post",
"rkey": rkey,
"record": {
"$type": "app.bsky.feed.post",
"text": text,
"createdAt": now,
}
}))
.send()
.await
.expect("putRecord");
assert_eq!(res.status(), StatusCode::OK);
}
async fn delete_record(client: &reqwest::Client, token: &str, did: &str, rkey: &str) {
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.deleteRecord",
base_url().await
))
.bearer_auth(token)
.json(&json!({
"repo": did,
"collection": "app.bsky.feed.post",
"rkey": rkey,
}))
.send()
.await
.expect("deleteRecord");
assert_eq!(res.status(), StatusCode::OK);
}
async fn apply_writes_batch(client: &reqwest::Client, token: &str, did: &str, writes: Vec<Value>) {
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.applyWrites",
base_url().await
))
.bearer_auth(token)
.json(&json!({ "repo": did, "writes": writes }))
.send()
.await
.expect("applyWrites");
assert_eq!(res.status(), StatusCode::OK);
}
fn rkey_for(i: usize) -> String {
format!("3ke2e{:08}", i)
}
#[tokio::test]
async fn websocket_firehose_frames_pass_inductive_forward_and_inverse() {
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 consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
tokio::time::sleep(Duration::from_millis(100)).await;
let now = chrono::Utc::now().to_rfc3339();
let seed: Vec<Value> = (0..120)
.map(|i| {
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": "app.bsky.feed.post",
"rkey": rkey_for(i),
"value": {
"$type": "app.bsky.feed.post",
"text": format!("e2e {i}"),
"createdAt": now,
}
})
})
.collect();
for chunk in seed.chunks(40) {
apply_writes_batch(&client, &token, &did, chunk.to_vec()).await;
}
for i in (0..120).step_by(6) {
put_record(&client, &token, &did, &rkey_for(i), &format!("upd {i}")).await;
}
for i in (2..120).step_by(11) {
delete_record(&client, &token, &did, &rkey_for(i)).await;
}
create_record(&client, &token, &did, "3ke2efinal001", "final").await;
let target_commits = 3 + 20 + 11 + 1;
let frames = consumer
.wait_for_commits(&did, target_commits, Duration::from_secs(90))
.await;
assert!(
frames.len() >= target_commits,
"expected {} commit frames, got {}",
target_commits,
frames.len()
);
let mut forward_failures = Vec::new();
let mut inverse_failures = Vec::new();
for frame in &frames {
if frame.prev_data.is_none() {
continue;
}
if frame.ops.is_empty() {
continue;
}
if let Err(msg) = verify_frame_forward(frame).await {
forward_failures.push(format!("seq={}: {msg}", frame.seq));
}
if let Err(msg) = verify_frame_inverse(frame).await {
inverse_failures.push(format!("seq={}: {msg}", frame.seq));
}
}
assert!(
forward_failures.is_empty(),
"forward verification failures:\n - {}",
forward_failures.join("\n - ")
);
assert!(
inverse_failures.is_empty(),
"inverse verification failures:\n - {}",
inverse_failures.join("\n - ")
);
}
#[tokio::test]
async fn websocket_firehose_car_root_matches_commit_cid() {
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 consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
tokio::time::sleep(Duration::from_millis(100)).await;
for i in 0..4 {
create_record(&client, &token, &did, &rkey_for(i), "ck").await;
}
let frames = consumer
.wait_for_commits(&did, 4, Duration::from_secs(10))
.await;
for frame in &frames {
let mut reader = CarReader::new(Cursor::new(&frame.blocks))
.await
.expect("CAR header");
let roots = reader.header().roots();
assert_eq!(roots.len(), 1, "CAR must have exactly one root");
assert_eq!(
roots[0], frame.commit,
"CAR root must equal frame commit CID"
);
let mut found = false;
while let Ok(Some((cid, _))) = reader.next_block().await {
if cid == frame.commit {
found = true;
}
}
assert!(found, "CAR body must contain commit block");
}
}
#[tokio::test]
async fn websocket_firehose_resumption_from_cursor_yields_valid_frames() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let repos = get_test_repos().await;
for i in 0..5 {
create_record(&client, &token, &did, &rkey_for(i), "pre").await;
}
let resume_cursor = repos.repo.get_max_seq().await.unwrap().as_i64();
for i in 5..12 {
create_record(&client, &token, &did, &rkey_for(i), "post").await;
}
let consumer = FirehoseConsumer::connect_with_cursor(app_port(), resume_cursor).await;
let frames = consumer
.wait_for_commits(&did, 7, Duration::from_secs(20))
.await;
assert!(
frames.len() >= 7,
"expected 7+ frames after cursor resume, got {}",
frames.len()
);
for frame in &frames {
if frame.prev_data.is_none() || frame.ops.is_empty() {
continue;
}
verify_frame_forward(frame)
.await
.unwrap_or_else(|e| panic!("resumed frame seq={} invalid: {e}", frame.seq));
}
}
#[tokio::test]
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 consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
tokio::time::sleep(Duration::from_millis(100)).await;
create_record(&client, &token, &did, "3ke2eprev01", "v1").await;
put_record(&client, &token, &did, "3ke2eprev01", "v2").await;
delete_record(&client, &token, &did, "3ke2eprev01").await;
let frames = consumer
.wait_for_commits(&did, 3, Duration::from_secs(10))
.await;
assert!(frames.len() >= 3);
for frame in &frames {
for op in &frame.ops {
match op.action {
RepoAction::Create => {
assert!(op.cid.is_some(), "create must have cid");
assert!(op.prev.is_none(), "create must not have prev");
}
RepoAction::Update => {
assert!(op.cid.is_some(), "update must have cid");
assert!(
op.prev.is_some(),
"v1.1 update must carry prev CID (seq={})",
frame.seq
);
}
RepoAction::Delete => {
assert!(op.cid.is_none(), "delete must have null cid");
assert!(
op.prev.is_some(),
"v1.1 delete must carry prev CID (seq={})",
frame.seq
);
}
}
}
}
}
#[tokio::test]
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 consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
tokio::time::sleep(Duration::from_millis(100)).await;
let now = chrono::Utc::now().to_rfc3339();
let writes: Vec<Value> = (0..30)
.map(|i| {
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": "app.bsky.feed.post",
"rkey": rkey_for(i),
"value": {
"$type": "app.bsky.feed.post",
"text": format!("rb {i}"),
"createdAt": now,
}
})
})
.collect();
apply_writes_batch(&client, &token, &did, writes).await;
let frames = consumer
.wait_for_commits(&did, 1, Duration::from_secs(10))
.await;
let last = frames.last().expect("frame");
let blocks = car_to_blocks(&last.blocks).await;
let storage = Arc::new(MemoryBlockStore::new_from_blocks(blocks));
let commit_bytes = storage
.get(&last.commit)
.await
.unwrap()
.expect("commit block");
let commit = Commit::from_cbor(&commit_bytes).unwrap();
let new_root_cid = *commit.data();
let mst = Mst::load(storage, new_root_cid, None);
let rehydrated_cid = mst.get_pointer().await.expect("rebuild mst");
assert_eq!(
rehydrated_cid, new_root_cid,
"MST loaded from CAR must yield same root as commit.data()"
);
for op in &last.ops {
if op.action == RepoAction::Create {
let expected_cid = op.cid.unwrap();
let got = mst
.get(&op.path)
.await
.expect("mst.get")
.unwrap_or_else(|| panic!("key {} missing from rebuilt tree", op.path));
assert_eq!(got, expected_cid, "record CID mismatch for {}", op.path);
let _ = Cid::from_str(&expected_cid.to_string()).unwrap();
}
}
}
@@ -0,0 +1,697 @@
mod common;
mod mst_verify;
use std::collections::BTreeMap;
use std::str::FromStr;
use std::sync::Arc;
use cid::Cid;
use common::*;
use jacquard_common::smol_str::SmolStr;
use jacquard_repo::commit::Commit;
use jacquard_repo::mst::{Mst, VerifiedWriteOp};
use jacquard_repo::storage::{BlockStore, MemoryBlockStore};
use mst_verify::{extract_event_blocks, inline_to_store};
use reqwest::StatusCode;
use serde_json::{Value, json};
use tranquil_db_traits::{RepoEventType, SequenceNumber, SequencedEvent};
use tranquil_types::Did;
async fn new_commit_data_cid(
storage: &Arc<MemoryBlockStore>,
commit_cid: &Cid,
) -> Result<Cid, String> {
let commit_bytes = storage
.get(commit_cid)
.await
.map_err(|e| format!("get commit: {e:?}"))?
.ok_or_else(|| format!("CAR missing commit block {commit_cid}"))?;
let commit = Commit::from_cbor(&commit_bytes).map_err(|e| format!("parse commit: {e:?}"))?;
Ok(*commit.data())
}
fn ops_json(event: &SequencedEvent) -> Result<&Vec<Value>, String> {
event
.ops
.as_ref()
.and_then(|v| v.as_array())
.ok_or_else(|| "event.ops not an array".into())
}
fn parse_op_to_verified(op: &Value) -> Result<VerifiedWriteOp, String> {
let action = op["action"].as_str().ok_or("op.action missing")?;
let path = op["path"].as_str().ok_or("op.path missing")?;
let key = SmolStr::new(path);
match action {
"create" => {
let cid_str = op["cid"].as_str().ok_or("create missing cid")?;
let cid = Cid::from_str(cid_str).map_err(|e| format!("parse cid: {e:?}"))?;
Ok(VerifiedWriteOp::Create { key, cid })
}
"update" => {
let cid_str = op["cid"].as_str().ok_or("update missing cid")?;
let cid = Cid::from_str(cid_str).map_err(|e| format!("parse cid: {e:?}"))?;
let prev_str = op["prev"].as_str().ok_or("update missing prev")?;
let prev = Cid::from_str(prev_str).map_err(|e| format!("parse prev: {e:?}"))?;
Ok(VerifiedWriteOp::Update { key, cid, prev })
}
"delete" => {
let prev_str = op["prev"].as_str().ok_or("delete missing prev")?;
let prev = Cid::from_str(prev_str).map_err(|e| format!("parse prev: {e:?}"))?;
Ok(VerifiedWriteOp::Delete { key, prev })
}
other => Err(format!("unknown op action: {other}")),
}
}
async fn verify_inductive_forward(event: &SequencedEvent) -> Result<(Cid, Cid), String> {
let prev_data_cid = event
.prev_data_cid
.as_ref()
.and_then(|c| c.to_cid())
.ok_or_else(|| "event missing prev_data_cid".to_string())?;
let commit_cid = event
.commit_cid
.as_ref()
.and_then(|c| c.to_cid())
.ok_or_else(|| "event missing commit_cid".to_string())?;
let storage = inline_to_store(extract_event_blocks(event)?);
let expected_new_data = new_commit_data_cid(&storage, &commit_cid).await?;
let mut mst = Mst::load(storage.clone(), prev_data_cid, None);
for op_value in ops_json(event)? {
let action = op_value["action"].as_str().ok_or("op.action missing")?;
let path = op_value["path"].as_str().ok_or("op.path missing")?;
match action {
"create" | "update" => {
let cid = Cid::from_str(op_value["cid"].as_str().ok_or("op.cid missing")?)
.map_err(|e| format!("parse op.cid: {e:?}"))?;
mst = mst
.add(path, cid)
.await
.map_err(|e| format!("mst.add({path}): {e:?}"))?;
}
"delete" => {
mst = mst
.delete(path)
.await
.map_err(|e| format!("mst.delete({path}): {e:?}"))?;
}
other => return Err(format!("unknown op action: {other}")),
}
}
let computed = mst
.persist()
.await
.map_err(|e| format!("mst.persist: {e:?}"))?;
Ok((expected_new_data, computed))
}
async fn verify_inductive_inverse(event: &SequencedEvent) -> Result<(Cid, Cid), String> {
let prev_data_cid = event
.prev_data_cid
.as_ref()
.and_then(|c| c.to_cid())
.ok_or_else(|| "event missing prev_data_cid".to_string())?;
let commit_cid = event
.commit_cid
.as_ref()
.and_then(|c| c.to_cid())
.ok_or_else(|| "event missing commit_cid".to_string())?;
let storage = inline_to_store(extract_event_blocks(event)?);
let new_data_cid = new_commit_data_cid(&storage, &commit_cid).await?;
let mut mst = Mst::load(storage.clone(), new_data_cid, None);
for op_value in ops_json(event)?.iter().rev() {
let verified = parse_op_to_verified(op_value)?;
let inverted = mst
.invert_op(verified.clone())
.await
.map_err(|e| format!("invert_op({verified:?}): {e:?}"))?;
if !inverted {
return Err(format!("op not invertible: {verified:?}"));
}
}
let computed_prev = mst
.get_pointer()
.await
.map_err(|e| format!("get_pointer: {e:?}"))?;
Ok((prev_data_cid, computed_prev))
}
fn report_failures(total: usize, failures: &[String], mode: &str) {
assert!(
failures.is_empty(),
"{} of {total} {mode} commit events failed inductive verification:\n - {}",
failures.len(),
failures.join("\n - "),
);
}
async fn apply_writes_batch(client: &reqwest::Client, token: &str, did: &str, writes: Vec<Value>) {
let payload = json!({ "repo": did, "writes": writes });
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.applyWrites",
base_url().await
))
.bearer_auth(token)
.json(&payload)
.send()
.await
.expect("applyWrites request failed");
assert_eq!(
res.status(),
StatusCode::OK,
"applyWrites failed: {:?}",
res.text().await
);
}
async fn create_record(client: &reqwest::Client, token: &str, did: &str, col: &str, rkey: &str) {
let now = chrono::Utc::now().to_rfc3339();
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.bearer_auth(token)
.json(&json!({
"repo": did,
"collection": col,
"rkey": rkey,
"record": {
"$type": col,
"text": format!("post {rkey}"),
"createdAt": now,
}
}))
.send()
.await
.expect("createRecord request failed");
assert_eq!(res.status(), StatusCode::OK, "createRecord failed");
}
async fn put_record(
client: &reqwest::Client,
token: &str,
did: &str,
col: &str,
rkey: &str,
text: &str,
) {
let now = chrono::Utc::now().to_rfc3339();
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(token)
.json(&json!({
"repo": did,
"collection": col,
"rkey": rkey,
"record": {
"$type": col,
"text": text,
"createdAt": now,
}
}))
.send()
.await
.expect("putRecord request failed");
assert_eq!(res.status(), StatusCode::OK, "putRecord failed");
}
async fn delete_record(client: &reqwest::Client, token: &str, did: &str, col: &str, rkey: &str) {
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.deleteRecord",
base_url().await
))
.bearer_auth(token)
.json(&json!({ "repo": did, "collection": col, "rkey": rkey }))
.send()
.await
.expect("deleteRecord request failed");
assert_eq!(res.status(), StatusCode::OK, "deleteRecord failed");
}
const COLLECTION: &str = "app.bsky.feed.post";
fn rkey_for(prefix: &str, i: usize) -> String {
format!("3k{prefix}{:08}", i)
}
async fn our_commit_events(did: &str) -> Vec<SequencedEvent> {
let repos = get_test_repos().await;
let typed_did = Did::new(did.to_string()).unwrap();
let events = repos
.repo
.get_events_since_seq(SequenceNumber::ZERO, None)
.await
.expect("get_events_since_seq");
events
.into_iter()
.filter(|e| e.did == typed_did && e.event_type == RepoEventType::Commit)
.collect()
}
#[tokio::test]
async fn inductive_forward_verifies_delete_commits() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let now = chrono::Utc::now().to_rfc3339();
const N_CREATE: usize = 200;
let all_writes: Vec<Value> = (0..N_CREATE)
.map(|i| {
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": COLLECTION,
"rkey": rkey_for("del", i),
"value": {
"$type": COLLECTION,
"text": format!("record {i}"),
"createdAt": now,
}
})
})
.collect();
for chunk in all_writes.chunks(50) {
apply_writes_batch(&client, &token, &did, chunk.to_vec()).await;
}
let delete_indices: Vec<usize> = (10..N_CREATE).step_by(7).collect();
for i in &delete_indices {
delete_record(&client, &token, &did, COLLECTION, &rkey_for("del", *i)).await;
}
let our = our_commit_events(&did).await;
let delete_events: Vec<&SequencedEvent> = our
.iter()
.filter(|e| {
ops_json(e)
.map(|arr| arr.iter().any(|op| op["action"].as_str() == Some("delete")))
.unwrap_or(false)
})
.collect();
assert_eq!(delete_events.len(), delete_indices.len());
let mut failures = Vec::new();
for e in &delete_events {
match verify_inductive_forward(e).await {
Ok((exp, got)) if exp == got => {}
Ok((exp, got)) => failures.push(format!(
"seq={}: root mismatch exp={exp} got={got}",
e.seq.as_i64()
)),
Err(msg) => failures.push(format!("seq={}: {msg}", e.seq.as_i64())),
}
}
report_failures(delete_events.len(), &failures, "delete forward");
}
#[tokio::test]
async fn inductive_forward_verifies_create_commits() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
for i in 0..60usize {
create_record(&client, &token, &did, COLLECTION, &rkey_for("cre", i)).await;
}
let our = our_commit_events(&did).await;
let create_events: Vec<&SequencedEvent> = our
.iter()
.filter(|e| {
ops_json(e)
.map(|arr| arr.iter().all(|op| op["action"].as_str() == Some("create")))
.unwrap_or(false)
&& e.prev_data_cid.is_some()
})
.collect();
assert!(!create_events.is_empty());
let mut failures = Vec::new();
for e in &create_events {
match verify_inductive_forward(e).await {
Ok((exp, got)) if exp == got => {}
Ok((exp, got)) => failures.push(format!(
"seq={}: root mismatch exp={exp} got={got}",
e.seq.as_i64()
)),
Err(msg) => failures.push(format!("seq={}: {msg}", e.seq.as_i64())),
}
}
report_failures(create_events.len(), &failures, "create forward");
}
#[tokio::test]
async fn inductive_forward_verifies_update_commits() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let now = chrono::Utc::now().to_rfc3339();
let creates: Vec<Value> = (0..80)
.map(|i| {
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": COLLECTION,
"rkey": rkey_for("upd", i),
"value": {
"$type": COLLECTION,
"text": format!("original {i}"),
"createdAt": now,
}
})
})
.collect();
for chunk in creates.chunks(40) {
apply_writes_batch(&client, &token, &did, chunk.to_vec()).await;
}
for i in (0..80).step_by(3) {
put_record(
&client,
&token,
&did,
COLLECTION,
&rkey_for("upd", i),
&format!("updated {i}"),
)
.await;
}
let our = our_commit_events(&did).await;
let update_events: Vec<&SequencedEvent> = our
.iter()
.filter(|e| {
ops_json(e)
.map(|arr| arr.iter().any(|op| op["action"].as_str() == Some("update")))
.unwrap_or(false)
})
.collect();
assert!(!update_events.is_empty());
let mut failures = Vec::new();
for e in &update_events {
match verify_inductive_forward(e).await {
Ok((exp, got)) if exp == got => {}
Ok((exp, got)) => failures.push(format!(
"seq={}: root mismatch exp={exp} got={got}",
e.seq.as_i64()
)),
Err(msg) => failures.push(format!("seq={}: {msg}", e.seq.as_i64())),
}
}
report_failures(update_events.len(), &failures, "update forward");
}
#[tokio::test]
async fn inductive_forward_verifies_mixed_applywrites() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let now = chrono::Utc::now().to_rfc3339();
let seed: Vec<Value> = (0..120)
.map(|i| {
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": COLLECTION,
"rkey": rkey_for("mix", i),
"value": {
"$type": COLLECTION,
"text": format!("seed {i}"),
"createdAt": now,
}
})
})
.collect();
for chunk in seed.chunks(40) {
apply_writes_batch(&client, &token, &did, chunk.to_vec()).await;
}
let mixed: Vec<Value> = (0..40)
.flat_map(|i| {
vec![
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": COLLECTION,
"rkey": rkey_for("mxc", i),
"value": {
"$type": COLLECTION,
"text": format!("new {i}"),
"createdAt": now,
}
}),
json!({
"$type": "com.atproto.repo.applyWrites#update",
"collection": COLLECTION,
"rkey": rkey_for("mix", i),
"value": {
"$type": COLLECTION,
"text": format!("updated-mix {i}"),
"createdAt": now,
}
}),
json!({
"$type": "com.atproto.repo.applyWrites#delete",
"collection": COLLECTION,
"rkey": rkey_for("mix", i + 60),
}),
]
})
.collect();
apply_writes_batch(&client, &token, &did, mixed).await;
let our = our_commit_events(&did).await;
let last = our
.iter()
.rfind(|e| e.prev_data_cid.is_some())
.expect("at least one non-genesis commit");
let actions: Vec<&str> = ops_json(last)
.unwrap()
.iter()
.filter_map(|op| op["action"].as_str())
.collect();
assert!(actions.contains(&"create"));
assert!(actions.contains(&"update"));
assert!(actions.contains(&"delete"));
let (exp, got) = verify_inductive_forward(last)
.await
.expect("mixed applyWrites forward verify");
assert_eq!(exp, got, "mixed applyWrites commit forward-verify mismatch");
}
#[tokio::test]
async fn inductive_inverse_verifies_every_commit() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let now = chrono::Utc::now().to_rfc3339();
let seed: Vec<Value> = (0..100)
.map(|i| {
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": COLLECTION,
"rkey": rkey_for("inv", i),
"value": {
"$type": COLLECTION,
"text": format!("seed {i}"),
"createdAt": now,
}
})
})
.collect();
for chunk in seed.chunks(50) {
apply_writes_batch(&client, &token, &did, chunk.to_vec()).await;
}
for i in (0..100).step_by(5) {
put_record(
&client,
&token,
&did,
COLLECTION,
&rkey_for("inv", i),
&format!("upd {i}"),
)
.await;
}
for i in (2..100).step_by(11) {
delete_record(&client, &token, &did, COLLECTION, &rkey_for("inv", i)).await;
}
let our = our_commit_events(&did).await;
let non_genesis: Vec<&SequencedEvent> = our
.iter()
.filter(|e| e.prev_data_cid.is_some() && ops_json(e).is_ok())
.collect();
assert!(!non_genesis.is_empty());
let mut failures = Vec::new();
for e in &non_genesis {
match verify_inductive_inverse(e).await {
Ok((exp, got)) if exp == got => {}
Ok((exp, got)) => failures.push(format!(
"seq={}: inverse root mismatch exp={exp} got={got}",
e.seq.as_i64()
)),
Err(msg) => failures.push(format!("seq={}: {msg}", e.seq.as_i64())),
}
}
report_failures(non_genesis.len(), &failures, "any inverse");
}
#[tokio::test]
async fn inductive_inverse_handles_same_rkey_in_batch() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let now = chrono::Utc::now().to_rfc3339();
let rkey = rkey_for("dup", 0);
create_record(&client, &token, &did, COLLECTION, &rkey).await;
let writes = vec![
json!({
"$type": "com.atproto.repo.applyWrites#update",
"collection": COLLECTION,
"rkey": rkey,
"value": {
"$type": COLLECTION,
"text": "v1",
"createdAt": now,
}
}),
json!({
"$type": "com.atproto.repo.applyWrites#update",
"collection": COLLECTION,
"rkey": rkey,
"value": {
"$type": COLLECTION,
"text": "v2",
"createdAt": now,
}
}),
];
apply_writes_batch(&client, &token, &did, writes).await;
let our = our_commit_events(&did).await;
let dup_event = our
.iter()
.find(|e| {
ops_json(e)
.map(|arr| {
arr.iter()
.filter(|op| op["action"].as_str() == Some("update"))
.count()
== 2
})
.unwrap_or(false)
})
.expect("commit event with two same-rkey updates");
let (exp, got) = verify_inductive_inverse(dup_event)
.await
.expect("inverse verify should succeed for same-rkey batch");
assert_eq!(
exp, got,
"inverse root mismatch for same-rkey batch: exp={exp} got={got}"
);
}
#[tokio::test]
async fn prev_cid_chain_walks_to_genesis() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
for i in 0..8 {
create_record(&client, &token, &did, COLLECTION, &rkey_for("cha", i)).await;
}
let our = our_commit_events(&did).await;
assert!(our.len() >= 2);
let last = our.last().unwrap();
let mut current_prev: Option<Cid> = last.prev_cid.as_ref().and_then(|c| c.to_cid());
let head_commit_cid = last
.commit_cid
.as_ref()
.and_then(|c| c.to_cid())
.expect("head commit_cid");
let by_commit: BTreeMap<Cid, &SequencedEvent> = our
.iter()
.filter_map(|e| {
e.commit_cid
.as_ref()
.and_then(|c| c.to_cid())
.map(|c| (c, e))
})
.collect();
let mut visited = 1;
while let Some(prev) = current_prev {
let e = by_commit
.get(&prev)
.unwrap_or_else(|| panic!("prev commit {prev} missing from event list"));
visited += 1;
current_prev = e.prev_cid.as_ref().and_then(|c| c.to_cid());
}
assert!(
visited >= 2,
"chain too short: visited={visited}, head_commit={head_commit_cid}"
);
assert_eq!(
visited,
our.len(),
"chain did not reach genesis: walked {visited}, have {}",
our.len()
);
}
#[tokio::test]
async fn record_bytes_present_in_car_for_creates() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let now = chrono::Utc::now().to_rfc3339();
let writes: Vec<Value> = (0..5)
.map(|i| {
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": COLLECTION,
"rkey": rkey_for("rec", i),
"value": {
"$type": COLLECTION,
"text": format!("rec {i}"),
"createdAt": now,
}
})
})
.collect();
apply_writes_batch(&client, &token, &did, writes).await;
let our = our_commit_events(&did).await;
let latest = our.iter().rfind(|e| e.prev_data_cid.is_some()).unwrap();
let inline = extract_event_blocks(latest).unwrap();
let have_cids: std::collections::HashSet<Cid> = inline
.iter()
.map(|b| Cid::read_bytes(b.cid_bytes.as_slice()).unwrap())
.collect();
for op in ops_json(latest).unwrap() {
if op["action"].as_str() == Some("create")
&& let Some(cid_str) = op["cid"].as_str()
{
let cid = Cid::from_str(cid_str).unwrap();
assert!(
have_cids.contains(&cid),
"create op record CID {cid} not present in CAR inline blocks"
);
}
}
}
+334
View File
@@ -0,0 +1,334 @@
mod common;
mod firehose;
mod helpers;
use std::collections::BTreeMap;
use std::io::Cursor;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use cid::Cid;
use common::*;
use firehose::FirehoseConsumer;
use helpers::build_car_with_signature;
use iroh_car::CarReader;
use jacquard_repo::commit::Commit;
use jacquard_repo::mst::Mst;
use jacquard_repo::storage::{BlockStore, MemoryBlockStore};
use k256::ecdsa::SigningKey;
use reqwest::StatusCode;
use serde_json::{Value, json};
use tranquil_db_traits::{EventBlocks, RepoEventType, SequenceNumber, SequencedEvent};
use tranquil_scopes::RepoAction;
use tranquil_types::Did;
async fn car_to_blocks(car_bytes: &[u8]) -> (Vec<Cid>, BTreeMap<Cid, Bytes>) {
let mut reader = CarReader::new(Cursor::new(car_bytes))
.await
.expect("parse CAR");
let roots = reader.header().roots().to_vec();
let mut blocks = BTreeMap::new();
while let Ok(Some((cid, data))) = reader.next_block().await {
blocks.insert(cid, Bytes::from(data));
}
(roots, blocks)
}
async fn create_post(client: &reqwest::Client, token: &str, did: &str, rkey: &str, text: &str) {
let now = chrono::Utc::now().to_rfc3339();
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.bearer_auth(token)
.json(&json!({
"repo": did,
"collection": "app.bsky.feed.post",
"rkey": rkey,
"record": {
"$type": "app.bsky.feed.post",
"text": text,
"createdAt": now,
}
}))
.send()
.await
.expect("createRecord");
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn getrepo_car_roundtrips_mst_structure_and_records() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let expected_records: Vec<(String, String)> = (0..20)
.map(|i| {
let rkey = format!("3krtp{:08}", i);
let text = format!("roundtrip record {i}");
(rkey, text)
})
.collect();
for (rkey, text) in &expected_records {
create_post(&client, &token, &did, rkey, text).await;
}
let res = client
.get(format!(
"{}/xrpc/com.atproto.sync.getRepo",
base_url().await
))
.query(&[("did", did.as_str())])
.send()
.await
.expect("getRepo");
assert_eq!(res.status(), StatusCode::OK);
let car_bytes = res.bytes().await.unwrap();
let (roots, block_map) = car_to_blocks(&car_bytes).await;
assert_eq!(roots.len(), 1, "CAR must have exactly one root");
let commit_cid = roots[0];
let storage = Arc::new(MemoryBlockStore::new_from_blocks(block_map));
let commit_bytes = storage
.get(&commit_cid)
.await
.unwrap()
.expect("CAR contains commit block");
let commit = Commit::from_cbor(&commit_bytes).expect("parse commit");
let data_cid = *commit.data();
let mst = Mst::load(storage.clone(), data_cid, None);
let loaded_root = mst.get_pointer().await.expect("load root");
assert_eq!(loaded_root, data_cid, "loaded MST pointer == commit.data()");
for (rkey, _) in &expected_records {
let path = format!("app.bsky.feed.post/{rkey}");
let leaf = mst
.get(&path)
.await
.expect("mst.get")
.unwrap_or_else(|| panic!("record {path} missing from exported MST"));
let leaf_bytes = storage
.get(&leaf)
.await
.unwrap()
.unwrap_or_else(|| panic!("record block {leaf} missing from CAR"));
assert!(!leaf_bytes.is_empty(), "record bytes empty");
}
}
#[tokio::test]
async fn concurrent_swap_commit_writes_serialize() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
create_post(&client, &token, &did, "3kswap00000001", "anchor").await;
let latest_res = client
.get(format!(
"{}/xrpc/com.atproto.sync.getLatestCommit",
base_url().await
))
.query(&[("did", did.as_str())])
.send()
.await
.expect("getLatestCommit");
assert_eq!(latest_res.status(), StatusCode::OK);
let latest: Value = latest_res.json().await.unwrap();
let swap_cid = latest["cid"].as_str().unwrap().to_string();
let now = chrono::Utc::now().to_rfc3339();
let payload_a = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"rkey": "3kswap00000002",
"record": {
"$type": "app.bsky.feed.post",
"text": "writer A",
"createdAt": now,
},
"swapCommit": swap_cid,
});
let payload_b = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"rkey": "3kswap00000003",
"record": {
"$type": "app.bsky.feed.post",
"text": "writer B",
"createdAt": now,
},
"swapCommit": swap_cid,
});
let base = base_url().await;
let (res_a, res_b) = tokio::join!(
client
.post(format!("{base}/xrpc/com.atproto.repo.putRecord"))
.bearer_auth(&token)
.json(&payload_a)
.send(),
client
.post(format!("{base}/xrpc/com.atproto.repo.putRecord"))
.bearer_auth(&token)
.json(&payload_b)
.send(),
);
let status_a = res_a.expect("A send").status();
let status_b = res_b.expect("B send").status();
let ok_a = status_a == StatusCode::OK;
let ok_b = status_b == StatusCode::OK;
assert!(
ok_a ^ ok_b,
"exactly one swap_commit write must succeed: status_a={status_a}, status_b={status_b}"
);
}
#[tokio::test]
async fn imported_repo_emits_commit_event_with_valid_car() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let signing_key = SigningKey::random(&mut rand::thread_rng());
let (car_bytes, _car_root_cid) = build_car_with_signature(&did, &signing_key);
let import_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.importRepo",
base_url().await
))
.bearer_auth(&token)
.header("Content-Type", "application/vnd.ipld.car")
.body(car_bytes)
.send()
.await
.expect("importRepo");
assert_eq!(
import_res.status(),
StatusCode::OK,
"import failed: {:?}",
import_res.text().await.unwrap_or_default()
);
let repos = get_test_repos().await;
let typed_did = Did::new(did.clone()).unwrap();
let events = repos
.repo
.get_events_since_seq(SequenceNumber::ZERO, None)
.await
.expect("events");
let our: Vec<&SequencedEvent> = events
.iter()
.filter(|e| e.did == typed_did && e.event_type == RepoEventType::Commit)
.collect();
let last = our.last().expect("at least one commit event after import");
let inline = match last.blocks.as_ref().expect("blocks present") {
EventBlocks::Inline(v) => v,
_ => panic!("expected inline blocks"),
};
assert!(
!inline.is_empty(),
"import event inline blocks must not be empty"
);
let have_commit = inline.iter().any(|b| {
let cid = Cid::read_bytes(b.cid_bytes.as_slice()).unwrap();
last.commit_cid
.as_ref()
.and_then(|c| c.to_cid())
.map(|commit_cid| cid == commit_cid)
.unwrap_or(false)
});
assert!(have_commit, "import event CAR must include commit block");
}
#[tokio::test]
async fn firehose_commit_block_bytes_roundtrip_to_same_cid() {
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 consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
tokio::time::sleep(Duration::from_millis(100)).await;
create_post(&client, &token, &did, "3krt001", "round-trip me").await;
let frames = consumer
.wait_for_commits(&did, 1, Duration::from_secs(10))
.await;
let frame = frames.last().expect("frame");
let (_, block_map) = car_to_blocks(&frame.blocks).await;
use sha2::{Digest, Sha256};
for (cid, bytes) in &block_map {
let mut hasher = Sha256::new();
hasher.update(bytes);
let hash = hasher.finalize();
let mh = multihash::Multihash::wrap(0x12, hash.as_slice()).expect("wrap");
let recomputed = Cid::new_v1(cid.codec(), mh);
assert_eq!(
recomputed, *cid,
"CAR block {cid} bytes do not hash back to same CID"
);
}
}
#[tokio::test]
async fn firehose_commit_car_contains_new_record_bytes_for_every_create() {
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 consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await;
tokio::time::sleep(Duration::from_millis(100)).await;
let now = chrono::Utc::now().to_rfc3339();
let writes: Vec<Value> = (0..8)
.map(|i| {
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": "app.bsky.feed.post",
"rkey": format!("3krec{:08}", i),
"value": {
"$type": "app.bsky.feed.post",
"text": format!("rec {i}"),
"createdAt": now,
}
})
})
.collect();
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.applyWrites",
base_url().await
))
.bearer_auth(&token)
.json(&json!({ "repo": did, "writes": writes }))
.send()
.await
.expect("applyWrites");
assert_eq!(res.status(), StatusCode::OK);
let frames = consumer
.wait_for_commits(&did, 1, Duration::from_secs(10))
.await;
let frame = frames.last().expect("frame");
let (_, block_map) = car_to_blocks(&frame.blocks).await;
for op in &frame.ops {
if op.action == RepoAction::Create {
let cid = op.cid.expect("create cid");
assert!(
block_map.contains_key(&cid),
"record CID {cid} for path {} missing from CAR",
op.path
);
}
}
}
@@ -0,0 +1,348 @@
mod common;
mod mst_verify;
use std::collections::HashMap;
use std::str::FromStr;
use cid::Cid;
use common::*;
use jacquard_common::smol_str::SmolStr;
use jacquard_repo::commit::Commit;
use jacquard_repo::mst::{Mst, VerifiedWriteOp};
use jacquard_repo::storage::BlockStore;
use mst_verify::{extract_event_blocks, inline_to_store};
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use reqwest::StatusCode;
use serde_json::{Value, json};
use tranquil_db_traits::{RepoEventType, SequenceNumber, SequencedEvent};
use tranquil_types::Did;
const COLLECTIONS: &[&str] = &[
"app.bsky.feed.post",
"app.bsky.feed.like",
"app.bsky.graph.follow",
"app.bsky.feed.repost",
];
#[derive(Copy, Clone, Debug)]
enum FuzzOp {
Create,
Update,
Delete,
}
fn pick_op(rng: &mut StdRng, have_keys: bool) -> FuzzOp {
match (have_keys, rng.gen_range(0..10)) {
(false, _) => FuzzOp::Create,
(_, 0..=5) => FuzzOp::Create,
(_, 6..=7) => FuzzOp::Update,
_ => FuzzOp::Delete,
}
}
fn random_rkey(rng: &mut StdRng) -> String {
let tid_char_pool = b"234567abcdefghijklmnopqrstuvwxyz";
let mut out = Vec::with_capacity(13);
(0..13).for_each(|_| {
let c = tid_char_pool[rng.gen_range(0..tid_char_pool.len())];
out.push(c);
});
String::from_utf8(out).unwrap()
}
fn random_collection(rng: &mut StdRng) -> &'static str {
COLLECTIONS[rng.gen_range(0..COLLECTIONS.len())]
}
fn record_for_collection(col: &str, text: &str, now: &str) -> Value {
match col {
"app.bsky.feed.post" | "app.bsky.feed.repost" | "app.bsky.feed.like" => json!({
"$type": col,
"text": text,
"createdAt": now,
}),
_ => json!({
"$type": col,
"subject": format!("did:plc:synthetic{text}"),
"createdAt": now,
}),
}
}
async fn verify_commit_forward_and_inverse(event: &SequencedEvent) -> Result<(), String> {
let prev_data = event
.prev_data_cid
.as_ref()
.and_then(|c| c.to_cid())
.ok_or("no prev_data_cid")?;
let commit_cid = event
.commit_cid
.as_ref()
.and_then(|c| c.to_cid())
.ok_or("no commit_cid")?;
let inline = extract_event_blocks(event)?;
let ops = event
.ops
.as_ref()
.and_then(|v| v.as_array())
.ok_or("ops not array")?;
let storage = inline_to_store(inline);
let commit_bytes = storage
.get(&commit_cid)
.await
.map_err(|e| format!("get commit: {e:?}"))?
.ok_or("missing commit block")?;
let commit = Commit::from_cbor(&commit_bytes).map_err(|e| format!("parse commit: {e:?}"))?;
let new_data = *commit.data();
let mut forward = Mst::load(storage.clone(), prev_data, None);
for op in ops {
let action = op["action"].as_str().ok_or("op.action")?;
let path = op["path"].as_str().ok_or("op.path")?;
match action {
"create" | "update" => {
let cid = Cid::from_str(op["cid"].as_str().ok_or("op.cid")?)
.map_err(|e| format!("{e:?}"))?;
forward = forward
.add(path, cid)
.await
.map_err(|e| format!("fwd add {path}: {e:?}"))?;
}
"delete" => {
forward = forward
.delete(path)
.await
.map_err(|e| format!("fwd delete {path}: {e:?}"))?;
}
other => return Err(format!("unknown action {other}")),
}
}
let got = forward
.persist()
.await
.map_err(|e| format!("persist: {e:?}"))?;
if got != new_data {
return Err(format!("forward root mismatch exp={new_data} got={got}"));
}
let mut inverse = Mst::load(storage, new_data, None);
for op in ops {
let action = op["action"].as_str().ok_or("op.action")?;
let path = op["path"].as_str().ok_or("op.path")?;
let key = SmolStr::new(path);
let verified = match action {
"create" => {
let cid = Cid::from_str(op["cid"].as_str().ok_or("op.cid")?)
.map_err(|e| format!("{e:?}"))?;
VerifiedWriteOp::Create { key, cid }
}
"update" => {
let cid = Cid::from_str(op["cid"].as_str().ok_or("op.cid")?)
.map_err(|e| format!("{e:?}"))?;
let prev = Cid::from_str(op["prev"].as_str().ok_or("op.prev")?)
.map_err(|e| format!("{e:?}"))?;
VerifiedWriteOp::Update { key, cid, prev }
}
"delete" => {
let prev = Cid::from_str(op["prev"].as_str().ok_or("op.prev")?)
.map_err(|e| format!("{e:?}"))?;
VerifiedWriteOp::Delete { key, prev }
}
other => return Err(format!("unknown action {other}")),
};
let inverted = inverse
.invert_op(verified.clone())
.await
.map_err(|e| format!("invert {verified:?}: {e:?}"))?;
if !inverted {
return Err(format!("op not invertible: {verified:?}"));
}
}
let got_prev = inverse
.get_pointer()
.await
.map_err(|e| format!("get_pointer: {e:?}"))?;
if got_prev != prev_data {
return Err(format!(
"inverse root mismatch exp={prev_data} got={got_prev}"
));
}
Ok(())
}
async fn fuzz_run_with_seed(seed: u64, steps: usize) -> Vec<String> {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let mut rng = StdRng::seed_from_u64(seed);
let mut live_keys: HashMap<String, String> = HashMap::new();
for step in 0..steps {
let now = chrono::Utc::now().to_rfc3339();
let op = pick_op(&mut rng, !live_keys.is_empty());
match op {
FuzzOp::Create => {
let col = random_collection(&mut rng);
let rkey = random_rkey(&mut rng);
let path = format!("{col}/{rkey}");
if live_keys.contains_key(&path) {
continue;
}
let record = record_for_collection(col, &format!("s{seed}-n{step}"), &now);
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.bearer_auth(&token)
.json(&json!({
"repo": did,
"collection": col,
"rkey": rkey,
"record": record,
}))
.send()
.await
.expect("createRecord");
if res.status() == StatusCode::OK {
live_keys.insert(path, col.to_string());
}
}
FuzzOp::Update => {
let keys: Vec<&String> = live_keys.keys().collect();
if keys.is_empty() {
continue;
}
let path = keys[rng.gen_range(0..keys.len())].clone();
let col = live_keys.get(&path).unwrap().clone();
let rkey = path.split('/').nth(1).unwrap().to_string();
let record = record_for_collection(&col, &format!("s{seed}-u{step}"), &now);
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(&token)
.json(&json!({
"repo": did,
"collection": col,
"rkey": rkey,
"record": record,
}))
.send()
.await
.expect("putRecord");
assert_eq!(res.status(), StatusCode::OK, "putRecord failed");
}
FuzzOp::Delete => {
let keys: Vec<String> = live_keys.keys().cloned().collect();
if keys.is_empty() {
continue;
}
let path = keys[rng.gen_range(0..keys.len())].clone();
let col = live_keys.get(&path).unwrap().clone();
let rkey = path.split('/').nth(1).unwrap().to_string();
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.deleteRecord",
base_url().await
))
.bearer_auth(&token)
.json(&json!({
"repo": did,
"collection": col,
"rkey": rkey,
}))
.send()
.await
.expect("deleteRecord");
if res.status() == StatusCode::OK {
live_keys.remove(&path);
}
}
}
}
let repos = get_test_repos().await;
let typed_did = Did::new(did.clone()).unwrap();
let events = repos
.repo
.get_events_since_seq(SequenceNumber::ZERO, None)
.await
.expect("get_events_since_seq");
let our: Vec<SequencedEvent> = events
.into_iter()
.filter(|e| {
e.did == typed_did
&& e.event_type == RepoEventType::Commit
&& e.prev_data_cid.is_some()
&& e.ops
.as_ref()
.and_then(|v| v.as_array())
.is_some_and(|a| !a.is_empty())
})
.collect();
let mut failures = Vec::new();
for event in &our {
if let Err(msg) = verify_commit_forward_and_inverse(event).await {
failures.push(format!(
"seed={seed} seq={} ops={:?}: {msg}",
event.seq.as_i64(),
event
.ops
.as_ref()
.and_then(|v| v.as_array())
.map(|a| a.len())
));
}
}
failures
}
#[tokio::test]
async fn mst_property_fuzz_seed_1() {
let failures = fuzz_run_with_seed(1, 150).await;
assert!(
failures.is_empty(),
"fuzz seed=1 found {} invalid commits:\n - {}",
failures.len(),
failures.join("\n - ")
);
}
#[tokio::test]
async fn mst_property_fuzz_seed_42() {
let failures = fuzz_run_with_seed(42, 150).await;
assert!(
failures.is_empty(),
"fuzz seed=42 found {} invalid commits:\n - {}",
failures.len(),
failures.join("\n - ")
);
}
#[tokio::test]
async fn mst_property_fuzz_seed_9001() {
let failures = fuzz_run_with_seed(9001, 150).await;
assert!(
failures.is_empty(),
"fuzz seed=9001 found {} invalid commits:\n - {}",
failures.len(),
failures.join("\n - ")
);
}
#[tokio::test]
async fn mst_property_fuzz_deep_tree_seed_7() {
let failures = fuzz_run_with_seed(7, 400).await;
assert!(
failures.is_empty(),
"fuzz deep seed=7 found {} invalid commits:\n - {}",
failures.len(),
failures.join("\n - ")
);
}

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