Compare commits

..
Author SHA1 Message Date
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
74 changed files with 6099 additions and 2646 deletions
+4
View File
@@ -72,6 +72,10 @@ 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(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"
@@ -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"
}
@@ -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,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": "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"
}
Generated
+24 -22
View File
@@ -7405,7 +7405,7 @@ dependencies = [
[[package]]
name = "tranquil-api"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"anyhow",
"axum",
@@ -7456,7 +7456,7 @@ dependencies = [
[[package]]
name = "tranquil-auth"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"anyhow",
"base32",
@@ -7479,7 +7479,7 @@ dependencies = [
[[package]]
name = "tranquil-cache"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7493,7 +7493,7 @@ dependencies = [
[[package]]
name = "tranquil-comms"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7511,7 +7511,7 @@ dependencies = [
[[package]]
name = "tranquil-config"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"confique",
"serde",
@@ -7519,7 +7519,7 @@ dependencies = [
[[package]]
name = "tranquil-crypto"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"aes-gcm",
"base64 0.22.1",
@@ -7535,7 +7535,7 @@ dependencies = [
[[package]]
name = "tranquil-db"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"async-trait",
"chrono",
@@ -7552,7 +7552,7 @@ dependencies = [
[[package]]
name = "tranquil-db-traits"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7568,7 +7568,7 @@ dependencies = [
[[package]]
name = "tranquil-infra"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"async-trait",
"bytes",
@@ -7579,7 +7579,7 @@ dependencies = [
[[package]]
name = "tranquil-lexicon"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"chrono",
"hickory-resolver",
@@ -7597,7 +7597,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"anyhow",
"axum",
@@ -7620,7 +7620,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth-server"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"axum",
"base64 0.22.1",
@@ -7653,7 +7653,7 @@ dependencies = [
[[package]]
name = "tranquil-pds"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"aes-gcm",
"anyhow",
@@ -7738,13 +7738,14 @@ dependencies = [
"urlencoding",
"uuid",
"webauthn-rs",
"webauthn-rs-proto",
"wiremock",
"zip",
]
[[package]]
name = "tranquil-repo"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"bytes",
"cid",
@@ -7756,7 +7757,7 @@ dependencies = [
[[package]]
name = "tranquil-ripple"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"async-trait",
"backon",
@@ -7781,7 +7782,7 @@ dependencies = [
[[package]]
name = "tranquil-scopes"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"axum",
"futures",
@@ -7797,7 +7798,7 @@ dependencies = [
[[package]]
name = "tranquil-server"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"axum",
"clap",
@@ -7818,7 +7819,7 @@ dependencies = [
[[package]]
name = "tranquil-signal"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"async-trait",
"chrono",
@@ -7841,7 +7842,7 @@ dependencies = [
[[package]]
name = "tranquil-storage"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"async-trait",
"aws-config",
@@ -7858,7 +7859,7 @@ dependencies = [
[[package]]
name = "tranquil-store"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"async-trait",
"bytes",
@@ -7871,6 +7872,7 @@ dependencies = [
"jacquard-common",
"jacquard-repo",
"k256",
"libc",
"lsm-tree",
"memmap2",
"multihash",
@@ -7904,7 +7906,7 @@ dependencies = [
[[package]]
name = "tranquil-sync"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"anyhow",
"axum",
@@ -7926,7 +7928,7 @@ dependencies = [
[[package]]
name = "tranquil-types"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"chrono",
"cid",
+2 -2
View File
@@ -26,7 +26,7 @@ members = [
]
[workspace.package]
version = "0.5.0"
version = "0.5.4"
edition = "2024"
license = "AGPL-3.0-or-later"
@@ -126,7 +126,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"] }
+5 -2
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 ./
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
+42 -10
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 themselvess 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 itself is compiled to a single binary with no nodeJS runtime. However, at time of writing, Tranquil requires postgres running separately.
## Quick Start
@@ -55,14 +63,38 @@ podman-compose -f docker-compose.prod.yaml up -d
- [Containers](docs/install-containers.md)
- [Kubernetes](docs/install-kubernetes.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 contributers
- [@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)
- 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
+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,
}
})
}
@@ -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();
@@ -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,
};
+4
View File
@@ -720,6 +720,10 @@ pub struct FirehoseConfig {
#[config(env = "FIREHOSE_MAX_LAG", default = 5000)]
pub max_lag: u64,
/// Maximum concurrent full-repo exports, eg. getRepo without `since`.
#[config(env = "MAX_CONCURRENT_REPO_EXPORTS", default = 4)]
pub max_concurrent_repo_exports: usize,
/// List of relay / crawler notification URLs.
#[config(env = "CRAWLERS", parse_env = split_comma_list)]
pub crawlers: Option<Vec<String>>,
+15 -2
View File
@@ -144,12 +144,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 +358,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>;
+58 -5
View File
@@ -1102,6 +1102,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 +1383,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 +1402,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 +1414,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 +1577,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)
+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)) => {
@@ -401,7 +401,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 +410,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,7 +486,7 @@ 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 {
@@ -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,91 @@ 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,
@@ -325,7 +402,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 +548,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 +564,35 @@ 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."
})),
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,
)
.into_response();
.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 +691,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,
@@ -1339,7 +1339,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,
};
+1
View File
@@ -78,6 +78,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 }
+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()))
}
}
+20 -9
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> {
+22 -54
View File
@@ -13,7 +13,6 @@ use jacquard_repo::mst::util::compute_cid;
use jacquard_repo::storage::BlockStore;
use k256::ecdsa::SigningKey;
use serde_json::{Value, json};
use std::collections::BTreeSet;
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::OwnedMutexGuard;
@@ -226,30 +225,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,
@@ -266,35 +241,28 @@ pub async fn finalize_repo_write(
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),
)
.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(),
)
},
)?;
let new_tree_cids: Vec<Cid> = block_bytes.keys().copied().collect();
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 result = commit_and_log(
state,
+14 -13
View File
@@ -667,6 +667,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 +685,20 @@ 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
});
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)
}
+4
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)]
@@ -394,6 +395,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,
)),
}
}
@@ -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
);
}
@@ -0,0 +1,175 @@
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()
.and_then(|_| Some(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"),
);
}
@@ -597,3 +597,155 @@ 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,543 @@
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,147 @@
mod common;
mod helpers;
use common::*;
use helpers::*;
use reqwest::StatusCode;
use std::sync::Once;
static SET_SEMAPHORE: Once = Once::new();
fn ensure_low_semaphore() {
SET_SEMAPHORE.call_once(|| unsafe {
std::env::set_var("MAX_CONCURRENT_REPO_EXPORTS", "1");
});
}
#[tokio::test]
async fn test_get_repo_succeeds_with_many_records() {
ensure_low_semaphore();
let client = client();
let (did, jwt) = setup_new_user("sync-batched-car").await;
let create_futures = (0..20).map(|i| {
let client = &client;
let did = &did;
let jwt = &jwt;
async move {
create_post(client, did, jwt, &format!("Batch test post {}", i)).await;
}
});
futures::future::join_all(create_futures).await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.sync.getRepo",
base_url().await
))
.query(&[("did", did.as_str())])
.send()
.await
.expect("Failed to send getRepo request");
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers()
.get("content-type")
.and_then(|h| h.to_str().ok()),
Some("application/vnd.ipld.car")
);
let car_bytes = res.bytes().await.expect("Failed to read response body");
assert!(
car_bytes.len() > 200,
"CAR with 20 records should have substantial data, got {} bytes",
car_bytes.len()
);
}
#[tokio::test]
async fn test_get_repo_semaphore_rejects_excess_concurrency() {
ensure_low_semaphore();
let client = client();
let (did, jwt) = setup_new_user("sync-semaphore").await;
for i in 0..50 {
create_post(&client, &did, &jwt, &format!("Padding post {}", i)).await;
}
let base = base_url().await;
let concurrent_requests = 10;
let request_futures = (0..concurrent_requests).map(|_| {
let client = client.clone();
let did = did.clone();
async move {
client
.get(format!("{}/xrpc/com.atproto.sync.getRepo", base))
.query(&[("did", did.as_str())])
.send()
.await
.expect("Failed to send request")
.status()
}
});
let statuses: Vec<StatusCode> = futures::future::join_all(request_futures).await;
let ok_count = statuses.iter().filter(|s| **s == StatusCode::OK).count();
let rejected_count = statuses
.iter()
.filter(|s| **s == StatusCode::SERVICE_UNAVAILABLE)
.count();
assert!(ok_count >= 1, "at least one request should succeed");
assert!(
rejected_count > 0,
"semaphore=1 with {} concurrent requests, expected some 503 rejections",
concurrent_requests
);
assert!(
ok_count + rejected_count == statuses.len(),
"expected only 200 or 503 responses: {:?}",
statuses
);
}
#[tokio::test]
async fn test_get_repo_since_not_affected_by_semaphore() {
ensure_low_semaphore();
let client = client();
let (did, jwt) = setup_new_user("sync-since-no-sem").await;
create_post(&client, &did, &jwt, "First post").await;
let latest_res = client
.get(format!(
"{}/xrpc/com.atproto.sync.getLatestCommit",
base_url().await
))
.query(&[("did", did.as_str())])
.send()
.await
.expect("Failed to get latest commit");
let body: serde_json::Value = latest_res.json().await.unwrap();
let rev = body["rev"].as_str().unwrap();
create_post(&client, &did, &jwt, "Second post").await;
let base = base_url().await;
let request_futures = (0..10).map(|_| {
let client = client.clone();
let did = did.clone();
let rev = rev.to_string();
async move {
client
.get(format!("{}/xrpc/com.atproto.sync.getRepo", base))
.query(&[("did", did.as_str()), ("since", rev.as_str())])
.send()
.await
.expect("Failed to send request")
.status()
}
});
let statuses: Vec<StatusCode> = futures::future::join_all(request_futures).await;
assert!(
statuses.iter().all(|s| *s == StatusCode::OK),
"getRepo with since should bypass semaphore, got: {:?}",
statuses
);
}
+28 -3
View File
@@ -157,11 +157,19 @@ impl ScopePermissions {
}
pub fn assert_rpc(&self, aud: &str, lxm: &str) -> Result<(), ScopeError> {
if self.has_transition_generic {
return Ok(());
if lxm.starts_with("chat.bsky.") {
if self.has_transition_chat {
return Ok(());
}
if self.has_transition_generic && !self.has_transition_chat {
return Err(ScopeError::InsufficientScope {
required: "transition:chat.bsky".to_string(),
message: format!("Chat access requires transition:chat.bsky scope to call {}", lxm),
});
}
}
if lxm.starts_with("chat.bsky.") && self.has_transition_chat {
if self.has_transition_generic {
return Ok(());
}
@@ -347,6 +355,23 @@ mod tests {
assert!(perms.allows_blob("image/png"));
}
#[test]
fn test_transition_generic_without_chat_blocks_chat() {
let perms = ScopePermissions::from_scope_string(Some("transition:generic"));
assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline"));
assert!(!perms.allows_rpc("did:web:api.bsky.app", "chat.bsky.convo.listConvos"));
assert!(!perms.allows_rpc("did:web:api.bsky.app", "chat.bsky.convo.getMessages"));
}
#[test]
fn test_transition_generic_with_chat_allows_chat() {
let perms =
ScopePermissions::from_scope_string(Some("transition:generic transition:chat.bsky"));
assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline"));
assert!(perms.allows_rpc("did:web:api.bsky.app", "chat.bsky.convo.listConvos"));
assert!(perms.allows_rpc("did:web:api.bsky.app", "chat.bsky.convo.getMessages"));
}
#[test]
fn test_transition_chat_only_allows_chat() {
let perms = ScopePermissions::from_scope_string(Some("transition:chat.bsky"));
+1
View File
@@ -52,6 +52,7 @@ k256 = { workspace = true }
rand = { workspace = true }
tikv-jemallocator = "0.6"
tracing-subscriber = { workspace = true, features = ["env-filter"] }
libc = "0.2"
[[bench]]
name = "blockstore"
+1 -3
View File
@@ -100,9 +100,7 @@ impl<'a> DirectSeeder<'a> {
}
let loc = self.data_writer.append_block(cid, data).unwrap();
self.hint_writer
.append_hint(cid, loc.file_id, loc.offset, loc.length)
.unwrap();
self.hint_writer.append_hint(cid, &loc).unwrap();
self.blocks_in_file += 1;
if self.blocks_in_file.is_multiple_of(10_000) {
@@ -93,8 +93,7 @@ pub(super) fn compact_on_writer_thread<S: StorageIO>(
Err(e)
}
Ok((new_size, live_count, dead_count)) => {
let positions = hint_positions.snapshot();
if let Err(e) = index.write_checkpoint(epoch.current(), &positions) {
if let Err(e) = index.write_checkpoint(epoch.current(), hint_positions) {
tracing::warn!(error = %e, "pre-delete checkpoint failed during compaction");
}
@@ -162,7 +161,7 @@ fn stream_compact<S: StorageIO>(
} => match index.get(&cid_bytes) {
Some(e) if e.location.file_id == source_file_id && !e.refcount.is_zero() => {
let loc = writer.append_block(&cid_bytes, &data)?;
hint_writer.append_relocate(&cid_bytes, loc.file_id, loc.offset, loc.length)?;
hint_writer.append_relocate(&cid_bytes, &loc, e.refcount.raw())?;
relocations.push((cid_bytes, loc));
live_count = live_count.saturating_add(1);
}
@@ -182,12 +181,7 @@ fn stream_compact<S: StorageIO>(
}
false => {
let loc = writer.append_block(&cid_bytes, &data)?;
hint_writer.append_relocate(
&cid_bytes,
loc.file_id,
loc.offset,
loc.length,
)?;
hint_writer.append_relocate(&cid_bytes, &loc, e.refcount.raw())?;
relocations.push((cid_bytes, loc));
live_count = live_count.saturating_add(1);
}
@@ -813,8 +813,7 @@ fn maybe_checkpoint(
if !elapsed && !threshold {
return;
}
let positions = hint_positions.snapshot();
match index.write_checkpoint(epoch.current(), &positions) {
match index.write_checkpoint(epoch.current(), hint_positions) {
Ok(()) => {
*last_checkpoint = std::time::Instant::now();
*writes_since_checkpoint = 0;
@@ -831,8 +830,7 @@ fn shutdown_checkpoint(
epoch: &EpochCounter,
hint_positions: &ShardHintPositions,
) {
let positions = hint_positions.snapshot();
match index.write_checkpoint(epoch.current(), &positions) {
match index.write_checkpoint(epoch.current(), hint_positions) {
Ok(()) => tracing::debug!("shutdown checkpoint written"),
Err(e) => tracing::warn!(error = %e, "shutdown checkpoint failed"),
}
@@ -924,8 +922,6 @@ fn commit_loop<S: StorageIO>(
if let Ok((ref dedup, _)) = result {
writes_since_checkpoint =
writes_since_checkpoint.saturating_add(dedup.len() as u64);
ctx.hint_positions
.update(ctx.shard_id, state.file_id, state.hint_position);
}
dispatch_responses(drain.entries, result.map(|(dedup, _proof)| dedup));
@@ -1024,8 +1020,6 @@ fn drain_and_process_remaining<S: StorageIO>(
if let Ok((ref _dedup, ref proof)) = result {
run_post_sync_hook(post_sync_hook, proof);
ctx.hint_positions
.update(ctx.shard_id, state.file_id, state.hint_position);
}
dispatch_responses(entries, result.map(|(dedup, _proof)| dedup));
@@ -1102,6 +1096,7 @@ fn process_batch<S: StorageIO>(
let location = match dedup.get(cid_bytes) {
Some(&loc) => {
dedup_hits = dedup_hits.saturating_add(1);
hint_writer.append_hint(cid_bytes, &loc)?;
loc
}
None => {
@@ -1136,7 +1131,7 @@ fn process_batch<S: StorageIO>(
}
let loc = data_writer.append_block(cid_bytes, data)?;
hint_writer.append_hint(cid_bytes, loc.file_id, loc.offset, loc.length)?;
hint_writer.append_hint(cid_bytes, &loc)?;
block_bytes = block_bytes.saturating_add(data.len() as u64);
block_count = block_count.saturating_add(1);
@@ -1194,7 +1189,19 @@ fn process_batch<S: StorageIO>(
};
let t = std::time::Instant::now();
index
.batch_put(&index_entries, &all_decrements, cursor, current_epoch, now)
.batch_put_and_advance_position(
&index_entries,
&all_decrements,
cursor,
current_epoch,
now,
super::hash_index::PositionUpdate {
hint_positions: &ctx.hint_positions,
shard_id: ctx.shard_id,
file_id: state.file_id,
offset: state.hint_position,
},
)
.map_err(CommitError::from)?;
let index_nanos = t.elapsed().as_nanos() as u64;
@@ -5,11 +5,19 @@ use std::path::{Path, PathBuf};
use parking_lot::RwLock;
use super::data_file::CID_SIZE;
use super::group_commit::ShardHintPositions;
use super::types::{
BlockLength, BlockLocation, BlockOffset, CidBytes, CollectionResult, CommitEpoch, DataFileId,
HintOffset, IndexEntry, LivenessInfo, RefCount, WallClockMs, WriteCursor,
HintOffset, IndexEntry, LivenessInfo, RefCount, ShardId, WallClockMs, WriteCursor,
};
pub struct PositionUpdate<'a> {
pub hint_positions: &'a ShardHintPositions,
pub shard_id: ShardId,
pub file_id: DataFileId,
pub offset: HintOffset,
}
const EMPTY_CID: [u8; CID_SIZE] = [0u8; CID_SIZE];
fn is_empty(cid: &[u8; CID_SIZE]) -> bool {
@@ -207,6 +215,10 @@ impl HashTable {
self.get(cid).is_some()
}
pub fn contains_live(&self, cid: &[u8; CID_SIZE]) -> bool {
self.get(cid).is_some_and(|s| !s.refcount.is_zero())
}
pub fn insert(&mut self, new_slot: Slot) -> Result<Option<Slot>, CapacityExhausted> {
if is_empty(&new_slot.cid) {
tracing::error!("attempted to insert all-zero CID into hash table");
@@ -378,6 +390,7 @@ impl HashTable {
&mut self,
cid: &[u8; CID_SIZE],
new_location: BlockLocation,
refcount: RefCount,
) -> Result<bool, CapacityExhausted> {
if is_empty(cid) {
return Ok(false);
@@ -394,7 +407,9 @@ impl HashTable {
let slot_cid = self.slots[idx].cid;
if is_empty(&slot_cid) {
self.slots[idx] = Slot::from_location(*cid, new_location);
let mut slot = Slot::from_location(*cid, new_location);
slot.refcount = refcount;
self.slots[idx] = slot;
self.count += 1;
return Ok(false);
}
@@ -411,6 +426,7 @@ impl HashTable {
let slot_dist = self.probe_distance(idx, slot_home);
if slot_dist < dist {
let mut displaced = Slot::from_location(*cid, new_location);
displaced.refcount = refcount;
std::mem::swap(&mut self.slots[idx], &mut displaced);
self.count += 1;
self.relocate_displaced(displaced, idx, slot_dist);
@@ -571,7 +587,7 @@ impl HashTable {
removals: &[CidBytes],
) {
relocations.iter().for_each(|(cid, new_loc)| {
if let Err(e) = self.relocate(cid, *new_loc) {
if let Err(e) = self.relocate(cid, *new_loc, RefCount::one()) {
tracing::error!(?e, "capacity exhausted during compaction relocation");
}
});
@@ -1183,7 +1199,7 @@ impl BlockIndex {
}
pub fn has(&self, cid: &[u8; CID_SIZE]) -> bool {
self.table.read().contains(cid)
self.table.read().contains_live(cid)
}
pub fn batch_put(
@@ -1193,6 +1209,30 @@ impl BlockIndex {
cursor: WriteCursor,
epoch: CommitEpoch,
now: WallClockMs,
) -> Result<(), BlockIndexError> {
self.batch_put_inner(entries, decrements, cursor, epoch, now, None)
}
pub fn batch_put_and_advance_position(
&self,
entries: &[([u8; CID_SIZE], BlockLocation)],
decrements: &[[u8; CID_SIZE]],
cursor: WriteCursor,
epoch: CommitEpoch,
now: WallClockMs,
position_update: PositionUpdate<'_>,
) -> Result<(), BlockIndexError> {
self.batch_put_inner(entries, decrements, cursor, epoch, now, Some(position_update))
}
fn batch_put_inner(
&self,
entries: &[([u8; CID_SIZE], BlockLocation)],
decrements: &[[u8; CID_SIZE]],
cursor: WriteCursor,
epoch: CommitEpoch,
now: WallClockMs,
position_update: Option<PositionUpdate<'_>>,
) -> Result<(), BlockIndexError> {
let mut table = self.table.write();
@@ -1213,6 +1253,12 @@ impl BlockIndex {
});
table.set_write_cursor(cursor);
if let Some(pos) = position_update {
pos.hint_positions
.update(pos.shard_id, pos.file_id, pos.offset);
}
Ok(())
}
@@ -1267,18 +1313,20 @@ impl BlockIndex {
pub fn batch_relocate(
&self,
relocations: &[(CidBytes, BlockLocation)],
relocations: &[(CidBytes, BlockLocation, u32)],
) -> Result<(), BlockIndexError> {
if relocations.is_empty() {
return Ok(());
}
let mut table = self.table.write();
relocations.iter().try_for_each(|(cid, location)| {
table
.relocate(cid, *location)
.map(|_| ())
.map_err(|_| BlockIndexError::CapacityExhausted)
})
relocations
.iter()
.try_for_each(|(cid, location, refcount)| {
table
.relocate(cid, *location, RefCount::new(*refcount))
.map(|_| ())
.map_err(|_| BlockIndexError::CapacityExhausted)
})
}
pub fn batch_remove(&self, cids: &[CidBytes]) {
@@ -1412,6 +1460,17 @@ impl BlockIndex {
}
pub fn write_checkpoint(
&self,
epoch: CommitEpoch,
hint_positions: &ShardHintPositions,
) -> io::Result<()> {
let _guard = self.checkpoint_lock.lock();
let table = self.table.read();
let positions = hint_positions.snapshot();
write_checkpoint_ab(&table, &self.index_dir, epoch, &positions)
}
pub fn write_checkpoint_with_positions(
&self,
epoch: CommitEpoch,
positions: &CheckpointPositions,
@@ -1499,15 +1558,18 @@ impl BlockIndex {
file_id,
offset,
length,
refcount,
} => {
let loc = BlockLocation {
file_id,
offset,
length,
};
table.relocate(&cid_bytes, loc).map_err(|_| {
io::Error::other("hash table capacity exhausted during rebuild")
})?;
table
.relocate(&cid_bytes, loc, RefCount::new(refcount))
.map_err(|_| {
io::Error::other("hash table capacity exhausted during rebuild")
})?;
}
ReadHintRecord::Remove { cid_bytes } => {
let _ = table.remove(&cid_bytes);
+86 -109
View File
@@ -55,22 +55,26 @@ fn write_hint_record<S: StorageIO>(
io.write_all_at(fd, write_offset.raw(), record)
}
fn encode_location_fields(record: &mut [u8; HINT_RECORD_SIZE], loc: &BlockLocation) {
record[FIELD_A_OFFSET..FIELD_A_OFFSET + 4].copy_from_slice(&loc.file_id.raw().to_le_bytes());
record[FIELD_A_OFFSET + 4..FIELD_A_OFFSET + 8]
.copy_from_slice(&loc.length.raw().to_le_bytes());
record[FIELD_B_OFFSET..FIELD_B_OFFSET + 8]
.copy_from_slice(&loc.offset.raw().to_le_bytes());
}
pub(crate) fn encode_hint_record<S: StorageIO>(
io: &S,
fd: FileId,
write_offset: HintOffset,
cid_bytes: &[u8; CID_SIZE],
file_id: DataFileId,
block_offset: BlockOffset,
length: BlockLength,
loc: &BlockLocation,
) -> io::Result<()> {
let mut record = [0u8; HINT_RECORD_SIZE];
record[TYPE_OFFSET] = RECORD_TYPE_PUT;
record[VERSION_OFFSET] = HINT_FORMAT_VERSION;
record[CID_OFFSET..CID_OFFSET + CID_SIZE].copy_from_slice(cid_bytes);
record[FIELD_A_OFFSET..FIELD_A_OFFSET + 4].copy_from_slice(&file_id.raw().to_le_bytes());
record[FIELD_A_OFFSET + 4..FIELD_A_OFFSET + 8].copy_from_slice(&length.raw().to_le_bytes());
record[FIELD_B_OFFSET..FIELD_B_OFFSET + 8].copy_from_slice(&block_offset.raw().to_le_bytes());
encode_location_fields(&mut record, loc);
let checksum = hint_checksum(&record[..HINT_PAYLOAD_SIZE]);
record[CHECKSUM_OFFSET..].copy_from_slice(&checksum.to_le_bytes());
@@ -78,22 +82,23 @@ pub(crate) fn encode_hint_record<S: StorageIO>(
write_hint_record(io, fd, write_offset, &record)
}
const REFCOUNT_OFFSET: usize = 2;
pub(crate) fn encode_relocate_record<S: StorageIO>(
io: &S,
fd: FileId,
write_offset: HintOffset,
cid_bytes: &[u8; CID_SIZE],
file_id: DataFileId,
block_offset: BlockOffset,
length: BlockLength,
loc: &BlockLocation,
refcount: u32,
) -> io::Result<()> {
let mut record = [0u8; HINT_RECORD_SIZE];
record[TYPE_OFFSET] = RECORD_TYPE_RELOCATE;
record[VERSION_OFFSET] = HINT_FORMAT_VERSION;
let rc16 = u16::try_from(refcount).unwrap_or(u16::MAX);
record[REFCOUNT_OFFSET..REFCOUNT_OFFSET + 2].copy_from_slice(&rc16.to_le_bytes());
record[CID_OFFSET..CID_OFFSET + CID_SIZE].copy_from_slice(cid_bytes);
record[FIELD_A_OFFSET..FIELD_A_OFFSET + 4].copy_from_slice(&file_id.raw().to_le_bytes());
record[FIELD_A_OFFSET + 4..FIELD_A_OFFSET + 8].copy_from_slice(&length.raw().to_le_bytes());
record[FIELD_B_OFFSET..FIELD_B_OFFSET + 8].copy_from_slice(&block_offset.raw().to_le_bytes());
encode_location_fields(&mut record, loc);
let checksum = hint_checksum(&record[..HINT_PAYLOAD_SIZE]);
record[CHECKSUM_OFFSET..].copy_from_slice(&checksum.to_le_bytes());
@@ -158,6 +163,7 @@ pub enum ReadHintRecord {
file_id: DataFileId,
offset: BlockOffset,
length: BlockLength,
refcount: u32,
},
Remove {
cid_bytes: [u8; CID_SIZE],
@@ -255,6 +261,15 @@ pub fn decode_hint_record<S: StorageIO>(
}))
}
RECORD_TYPE_RELOCATE => {
let rc16 = u16::from_le_bytes(
record[REFCOUNT_OFFSET..REFCOUNT_OFFSET + 2]
.try_into()
.unwrap(),
);
let refcount = match rc16 {
0 => 1,
n => u32::from(n),
};
let file_id = DataFileId::new(u32::from_le_bytes(
record[FIELD_A_OFFSET..FIELD_A_OFFSET + 4]
.try_into()
@@ -278,6 +293,7 @@ pub fn decode_hint_record<S: StorageIO>(
file_id,
offset: block_offset,
length: BlockLength::new(raw_length),
refcount,
}))
}
RECORD_TYPE_REMOVE => Ok(Some(ReadHintRecord::Remove { cid_bytes })),
@@ -307,19 +323,9 @@ impl<'a, S: StorageIO> HintFileWriter<'a, S> {
pub fn append_hint(
&mut self,
cid_bytes: &[u8; CID_SIZE],
file_id: DataFileId,
offset: BlockOffset,
length: BlockLength,
loc: &BlockLocation,
) -> io::Result<()> {
encode_hint_record(
self.io,
self.fd,
self.position,
cid_bytes,
file_id,
offset,
length,
)?;
encode_hint_record(self.io, self.fd, self.position, cid_bytes, loc)?;
self.position = self.position.advance(HINT_RECORD_SIZE as u64);
Ok(())
}
@@ -338,19 +344,10 @@ impl<'a, S: StorageIO> HintFileWriter<'a, S> {
pub fn append_relocate(
&mut self,
cid_bytes: &[u8; CID_SIZE],
file_id: DataFileId,
offset: BlockOffset,
length: BlockLength,
loc: &BlockLocation,
refcount: u32,
) -> io::Result<()> {
encode_relocate_record(
self.io,
self.fd,
self.position,
cid_bytes,
file_id,
offset,
length,
)?;
encode_relocate_record(self.io, self.fd, self.position, cid_bytes, loc, refcount)?;
self.position = self.position.advance(HINT_RECORD_SIZE as u64);
Ok(())
}
@@ -575,7 +572,7 @@ pub fn replay_hints_into_block_index<S: StorageIO>(
let mut replayed: u64 = 0;
let mut put_buffer: Vec<([u8; CID_SIZE], BlockLocation)> =
Vec::with_capacity(REPLAY_BATCH_SIZE);
let mut relocate_buffer: Vec<([u8; CID_SIZE], BlockLocation)> =
let mut relocate_buffer: Vec<([u8; CID_SIZE], BlockLocation, u32)> =
Vec::with_capacity(REPLAY_BATCH_SIZE);
let mut remove_buffer: Vec<[u8; CID_SIZE]> = Vec::with_capacity(REPLAY_BATCH_SIZE);
@@ -663,13 +660,14 @@ pub fn replay_hints_into_block_index<S: StorageIO>(
file_id,
offset,
length,
refcount,
} => {
let loc = BlockLocation {
file_id,
offset,
length,
};
relocate_buffer.push((cid_bytes, loc));
relocate_buffer.push((cid_bytes, loc, refcount));
let record_end =
offset.advance(BLOCK_RECORD_OVERHEAD as u64 + length.as_u64());
@@ -843,7 +841,8 @@ mod tests {
let offset = BlockOffset::new(1024);
let length = BlockLength::new(256);
encode_hint_record(&sim, fd, HintOffset::new(0), &cid, file_id, offset, length).unwrap();
let loc = BlockLocation { file_id, offset, length };
encode_hint_record(&sim, fd, HintOffset::new(0), &cid, &loc).unwrap();
let file_size = sim.file_size(fd).unwrap();
let record = decode_hint_record(&sim, fd, HintOffset::new(0), file_size)
@@ -901,16 +900,12 @@ mod tests {
(0u8..5).for_each(|i| {
let cid = test_cid(i);
let write_offset = HintOffset::new(i as u64 * HINT_RECORD_SIZE as u64);
encode_hint_record(
&sim,
fd,
write_offset,
&cid,
DataFileId::new(i as u32),
BlockOffset::new(i as u64 * 100),
BlockLength::new(50 + i as u32),
)
.unwrap();
let loc = BlockLocation {
file_id: DataFileId::new(i as u32),
offset: BlockOffset::new(i as u64 * 100),
length: BlockLength::new(50 + i as u32),
};
encode_hint_record(&sim, fd, write_offset, &cid, &loc).unwrap();
});
let file_size = sim.file_size(fd).unwrap();
@@ -952,16 +947,12 @@ mod tests {
fn detects_corrupted_hint() {
let (sim, fd) = setup();
let cid = test_cid(1);
encode_hint_record(
&sim,
fd,
HintOffset::new(0),
&cid,
DataFileId::new(0),
BlockOffset::new(0),
BlockLength::new(100),
)
.unwrap();
let loc = BlockLocation {
file_id: DataFileId::new(0),
offset: BlockOffset::new(0),
length: BlockLength::new(100),
};
encode_hint_record(&sim, fd, HintOffset::new(0), &cid, &loc).unwrap();
sim.write_all_at(fd, 10, &[0xFF]).unwrap();
@@ -987,16 +978,12 @@ mod tests {
fn oversized_length_treated_as_corrupted() {
let (sim, fd) = setup();
let cid = test_cid(1);
encode_hint_record(
&sim,
fd,
HintOffset::new(0),
&cid,
DataFileId::new(0),
BlockOffset::new(0),
BlockLength::new(100),
)
.unwrap();
let loc = BlockLocation {
file_id: DataFileId::new(0),
offset: BlockOffset::new(0),
length: BlockLength::new(100),
};
encode_hint_record(&sim, fd, HintOffset::new(0), &cid, &loc).unwrap();
let length_offset = FIELD_A_OFFSET as u64 + 4;
let oversized = (MAX_BLOCK_SIZE + 1).to_le_bytes();
@@ -1021,14 +1008,12 @@ mod tests {
let mut writer = HintFileWriter::new(&sim, fd);
(0u8..5).for_each(|i| {
writer
.append_hint(
&test_cid(i),
DataFileId::new(0),
BlockOffset::new(i as u64 * 100),
BlockLength::new(50 + i as u32),
)
.unwrap();
let loc = BlockLocation {
file_id: DataFileId::new(0),
offset: BlockOffset::new(i as u64 * 100),
length: BlockLength::new(50 + i as u32),
};
writer.append_hint(&test_cid(i), &loc).unwrap();
});
assert_eq!(
@@ -1055,25 +1040,21 @@ mod tests {
fn hint_writer_resume_continues_at_position() {
let (sim, fd) = setup();
let mut writer = HintFileWriter::new(&sim, fd);
writer
.append_hint(
&test_cid(0),
DataFileId::new(0),
BlockOffset::new(0),
BlockLength::new(100),
)
.unwrap();
let loc0 = BlockLocation {
file_id: DataFileId::new(0),
offset: BlockOffset::new(0),
length: BlockLength::new(100),
};
writer.append_hint(&test_cid(0), &loc0).unwrap();
let pos = writer.position();
let mut writer2 = HintFileWriter::resume(&sim, fd, pos);
writer2
.append_hint(
&test_cid(1),
DataFileId::new(0),
BlockOffset::new(100),
BlockLength::new(200),
)
.unwrap();
let loc1 = BlockLocation {
file_id: DataFileId::new(0),
offset: BlockOffset::new(100),
length: BlockLength::new(200),
};
writer2.append_hint(&test_cid(1), &loc1).unwrap();
let reader = HintFileReader::open(&sim, fd).unwrap();
let valid_count = reader
@@ -1096,14 +1077,12 @@ mod tests {
fn hint_reader_stops_on_truncated() {
let (sim, fd) = setup();
let mut writer = HintFileWriter::new(&sim, fd);
writer
.append_hint(
&test_cid(0),
DataFileId::new(0),
BlockOffset::new(0),
BlockLength::new(100),
)
.unwrap();
let loc = BlockLocation {
file_id: DataFileId::new(0),
offset: BlockOffset::new(0),
length: BlockLength::new(100),
};
writer.append_hint(&test_cid(0), &loc).unwrap();
sim.write_all_at(fd, writer.position().raw(), &[0u8; HINT_RECORD_SIZE - 1])
.unwrap();
@@ -1121,14 +1100,12 @@ mod tests {
let mut writer = HintFileWriter::new(&sim, fd);
(0u8..3).for_each(|i| {
writer
.append_hint(
&test_cid(i),
DataFileId::new(0),
BlockOffset::new(i as u64 * 100),
BlockLength::new(50),
)
.unwrap();
let loc = BlockLocation {
file_id: DataFileId::new(0),
offset: BlockOffset::new(i as u64 * 100),
length: BlockLength::new(50),
};
writer.append_hint(&test_cid(i), &loc).unwrap();
});
sim.write_all_at(fd, HINT_RECORD_SIZE as u64 + 5, &[0xFF])
+45 -4
View File
@@ -3487,13 +3487,13 @@ impl<S: StorageIO + 'static> tranquil_db_traits::UserRepository for MetastoreCli
recv(rx).await
}
async fn get_login_check_by_handle_or_email(
async fn get_login_check_by_identifier(
&self,
identifier: &str,
) -> Result<Option<UserLoginCheck>, DbError> {
let (tx, rx) = oneshot::channel();
self.pool.send(MetastoreRequest::User(
UserRequest::GetLoginCheckByHandleOrEmail {
UserRequest::GetLoginCheckByIdentifier {
identifier: identifier.to_owned(),
tx,
},
@@ -3501,13 +3501,13 @@ impl<S: StorageIO + 'static> tranquil_db_traits::UserRepository for MetastoreCli
recv(rx).await
}
async fn get_login_info_by_handle_or_email(
async fn get_login_info_by_identifier(
&self,
identifier: &str,
) -> Result<Option<UserLoginInfo>, DbError> {
let (tx, rx) = oneshot::channel();
self.pool.send(MetastoreRequest::User(
UserRequest::GetLoginInfoByHandleOrEmail {
UserRequest::GetLoginInfoByIdentifier {
identifier: identifier.to_owned(),
tx,
},
@@ -4233,6 +4233,47 @@ impl<S: StorageIO + 'static> tranquil_db_traits::UserRepository for MetastoreCli
recv(rx).await
}
async fn save_discoverable_challenge(
&self,
request_key: &str,
state_json: &str,
) -> Result<Uuid, DbError> {
let (tx, rx) = oneshot::channel();
self.pool.send(MetastoreRequest::User(
UserRequest::SaveDiscoverableChallenge {
request_key: request_key.to_owned(),
state_json: state_json.to_owned(),
tx,
},
))?;
recv(rx).await
}
async fn load_discoverable_challenge(
&self,
request_key: &str,
) -> Result<Option<String>, DbError> {
let (tx, rx) = oneshot::channel();
self.pool.send(MetastoreRequest::User(
UserRequest::LoadDiscoverableChallenge {
request_key: request_key.to_owned(),
tx,
},
))?;
recv(rx).await
}
async fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), DbError> {
let (tx, rx) = oneshot::channel();
self.pool.send(MetastoreRequest::User(
UserRequest::DeleteDiscoverableChallenge {
request_key: request_key.to_owned(),
tx,
},
))?;
recv(rx).await
}
async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError> {
let (tx, rx) = oneshot::channel();
self.pool
+47 -9
View File
@@ -996,11 +996,11 @@ pub enum UserRequest {
email: String,
tx: Tx<Option<UserForVerification>>,
},
GetLoginCheckByHandleOrEmail {
GetLoginCheckByIdentifier {
identifier: String,
tx: Tx<Option<UserLoginCheck>>,
},
GetLoginInfoByHandleOrEmail {
GetLoginInfoByIdentifier {
identifier: String,
tx: Tx<Option<UserLoginInfo>>,
},
@@ -1273,6 +1273,19 @@ pub enum UserRequest {
challenge_type: WebauthnChallengeType,
tx: Tx<()>,
},
SaveDiscoverableChallenge {
request_key: String,
state_json: String,
tx: Tx<Uuid>,
},
LoadDiscoverableChallenge {
request_key: String,
tx: Tx<Option<String>>,
},
DeleteDiscoverableChallenge {
request_key: String,
tx: Tx<()>,
},
GetTotpRecord {
did: Did,
tx: Tx<Option<TotpRecord>>,
@@ -1726,8 +1739,8 @@ impl UserRequest {
| Self::GetAnyAdminUserId { .. }
| Self::SearchAccounts { .. }
| Self::GetByEmail { .. }
| Self::GetLoginCheckByHandleOrEmail { .. }
| Self::GetLoginInfoByHandleOrEmail { .. }
| Self::GetLoginCheckByIdentifier { .. }
| Self::GetLoginInfoByIdentifier { .. }
| Self::CheckEmailVerifiedByIdentifier { .. }
| Self::StoreTelegramChatId { .. }
| Self::StoreDiscordUserId { .. }
@@ -1743,7 +1756,10 @@ impl UserRequest {
| Self::CleanupExpiredHandleReservations { .. }
| Self::CheckAndConsumeInviteCode { .. }
| Self::GetPasswordResetInfo { .. }
| Self::ExpirePasswordResetCode { .. } => Routing::Global,
| Self::ExpirePasswordResetCode { .. }
| Self::SaveDiscoverableChallenge { .. }
| Self::LoadDiscoverableChallenge { .. }
| Self::DeleteDiscoverableChallenge { .. } => Routing::Global,
}
}
}
@@ -5066,15 +5082,15 @@ fn dispatch_user<S: StorageIO + 'static>(state: &HandlerState<S>, req: UserReque
UserRequest::GetByEmail { email, tx } => {
let _ = tx.send(user.get_by_email(&email).map_err(metastore_to_db));
}
UserRequest::GetLoginCheckByHandleOrEmail { identifier, tx } => {
UserRequest::GetLoginCheckByIdentifier { identifier, tx } => {
let _ = tx.send(
user.get_login_check_by_handle_or_email(&identifier)
user.get_login_check_by_identifier(&identifier)
.map_err(metastore_to_db),
);
}
UserRequest::GetLoginInfoByHandleOrEmail { identifier, tx } => {
UserRequest::GetLoginInfoByIdentifier { identifier, tx } => {
let _ = tx.send(
user.get_login_info_by_handle_or_email(&identifier)
user.get_login_info_by_identifier(&identifier)
.map_err(metastore_to_db),
);
}
@@ -5434,6 +5450,28 @@ fn dispatch_user<S: StorageIO + 'static>(state: &HandlerState<S>, req: UserReque
.map_err(metastore_to_db),
);
}
UserRequest::SaveDiscoverableChallenge {
request_key,
state_json,
tx,
} => {
let _ = tx.send(
user.save_discoverable_challenge(&request_key, &state_json)
.map_err(metastore_to_db),
);
}
UserRequest::LoadDiscoverableChallenge { request_key, tx } => {
let _ = tx.send(
user.load_discoverable_challenge(&request_key)
.map_err(metastore_to_db),
);
}
UserRequest::DeleteDiscoverableChallenge { request_key, tx } => {
let _ = tx.send(
user.delete_discoverable_challenge(&request_key)
.map_err(metastore_to_db),
);
}
UserRequest::GetTotpRecord { did, tx } => {
let _ = tx.send(user.get_totp_record(&did).map_err(metastore_to_db));
}
+56 -11
View File
@@ -125,15 +125,9 @@ impl UserOps {
}
fn load_by_identifier(&self, identifier: &str) -> Result<Option<UserValue>, MetastoreError> {
match identifier.contains('@') {
true => self.load_by_email(identifier).and_then(|opt| match opt {
Some(v) => Ok(Some(v)),
None => self.load_by_handle(identifier),
}),
false => self.load_by_handle(identifier).and_then(|opt| match opt {
Some(v) => Ok(Some(v)),
None => self.load_by_email(identifier),
}),
match identifier.starts_with("did:") {
true => self.load_user_by_did(identifier),
false => self.load_by_handle(identifier),
}
}
@@ -472,7 +466,7 @@ impl UserOps {
.transpose()
}
pub fn get_login_check_by_handle_or_email(
pub fn get_login_check_by_identifier(
&self,
identifier: &str,
) -> Result<Option<UserLoginCheck>, MetastoreError> {
@@ -487,7 +481,7 @@ impl UserOps {
.transpose()
}
pub fn get_login_info_by_handle_or_email(
pub fn get_login_info_by_identifier(
&self,
identifier: &str,
) -> Result<Option<UserLoginInfo>, MetastoreError> {
@@ -1511,6 +1505,57 @@ impl UserOps {
.map_err(MetastoreError::Fjall)
}
const DISCOVERABLE_CHALLENGE_TYPE: u8 = 2;
pub fn save_discoverable_challenge(
&self,
request_key: &str,
state_json: &str,
) -> Result<Uuid, MetastoreError> {
let key_hash = UserHash::from_did(request_key);
let id = Uuid::new_v4();
let now_ms = Utc::now().timestamp_millis();
let value = WebauthnChallengeValue {
id,
challenge_type: Self::DISCOVERABLE_CHALLENGE_TYPE,
state_json: state_json.to_owned(),
created_at_ms: now_ms,
};
let key = webauthn_challenge_key(key_hash, Self::DISCOVERABLE_CHALLENGE_TYPE);
self.auth
.insert(key.as_slice(), value.serialize_with_ttl())
.map_err(MetastoreError::Fjall)?;
Ok(id)
}
pub fn load_discoverable_challenge(
&self,
request_key: &str,
) -> Result<Option<String>, MetastoreError> {
let key_hash = UserHash::from_did(request_key);
let key = webauthn_challenge_key(key_hash, Self::DISCOVERABLE_CHALLENGE_TYPE);
let val: Option<WebauthnChallengeValue> = point_lookup(
&self.auth,
key.as_slice(),
WebauthnChallengeValue::deserialize,
"corrupt webauthn challenge",
)?;
Ok(val.map(|v| v.state_json))
}
pub fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), MetastoreError> {
let key_hash = UserHash::from_did(request_key);
let key = webauthn_challenge_key(key_hash, Self::DISCOVERABLE_CHALLENGE_TYPE);
self.auth
.remove(key.as_slice())
.map_err(MetastoreError::Fjall)
}
pub fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, MetastoreError> {
let user_hash = self.resolve_hash(did.as_str());
let key = totp_key(user_hash);
@@ -0,0 +1,223 @@
mod common;
use std::io;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use tranquil_store::blockstore::{
BlockStoreConfig, BlocksSynced, CidBytes, GroupCommitConfig, TranquilBlockStore,
};
use tranquil_store::PostBlockstoreHook;
struct SlowHook;
impl PostBlockstoreHook for SlowHook {
fn on_blocks_synced(&self, _proof: &BlocksSynced) -> io::Result<()> {
std::thread::sleep(std::time::Duration::from_millis(1));
Ok(())
}
}
fn refcount(store: &TranquilBlockStore, cid: &CidBytes) -> Option<u32> {
store.block_index().get(cid).map(|e| e.refcount.raw())
}
fn race_config(dir: &std::path::Path) -> BlockStoreConfig {
BlockStoreConfig {
data_dir: dir.join("data"),
index_dir: dir.join("index"),
max_file_size: 256 * 1024,
group_commit: GroupCommitConfig {
checkpoint_interval_ms: 10,
checkpoint_write_threshold: 20,
..GroupCommitConfig::default()
},
shard_count: 4,
}
}
fn cid_for(shard: u8, seq: u32) -> CidBytes {
let mut cid = [0u8; 36];
cid[0] = 0x01;
cid[1] = 0x71;
cid[2] = 0x12;
cid[3] = 0x20;
cid[4] = shard;
cid[8..12].copy_from_slice(&seq.to_le_bytes());
(12..36).for_each(|i| cid[i] = (seq as u8).wrapping_add(i as u8));
cid
}
fn write_phase(base: &std::path::Path, use_hook: bool) -> Vec<CidBytes> {
let config = race_config(base);
let hook: Option<Arc<dyn PostBlockstoreHook>> = use_hook.then(|| Arc::new(SlowHook) as _);
let store = Arc::new(TranquilBlockStore::open_with_hook(config, hook).unwrap());
let running = Arc::new(AtomicBool::new(true));
let total_cycles = Arc::new(AtomicU64::new(0));
let writers: Vec<_> = (0..4u8)
.map(|shard| {
let store = Arc::clone(&store);
let running = Arc::clone(&running);
let total_cycles = Arc::clone(&total_cycles);
std::thread::spawn(move || {
let mut targets = Vec::new();
let mut seq = 0u32;
while running.load(Ordering::Relaxed) {
let cid = cid_for(shard, seq);
store
.put_blocks_blocking(vec![(cid, vec![shard; 60])])
.unwrap();
store
.put_blocks_blocking(vec![(cid, vec![shard; 60])])
.unwrap();
store
.apply_commit_blocking(vec![], vec![cid])
.unwrap();
targets.push(cid);
seq += 1;
total_cycles.fetch_add(1, Ordering::Relaxed);
}
targets
})
})
.collect();
while total_cycles.load(Ordering::Relaxed) < 500 {
std::thread::yield_now();
}
running.store(false, Ordering::Relaxed);
let all_targets: Vec<CidBytes> = writers
.into_iter()
.flat_map(|w| w.join().unwrap())
.collect();
all_targets.iter().for_each(|cid| {
assert_eq!(refcount(&store, cid), Some(1), "pre-crash sanity");
});
let store = Arc::try_unwrap(store).ok().unwrap();
std::mem::forget(store);
all_targets
}
fn verify_phase(base: &std::path::Path, targets: &[CidBytes]) -> usize {
let config = race_config(base);
let store = TranquilBlockStore::open(config).unwrap();
let bad = targets
.iter()
.filter(|cid| refcount(&store, cid) != Some(1))
.count();
drop(store);
bad
}
#[test]
fn crash_recovery_preserves_refcounts() {
common::with_runtime(|| {
let mut corrupted = 0u32;
let total = 20u32;
(0..total).for_each(|_| {
let dir = tempfile::TempDir::new().unwrap();
let exe = std::env::current_exe().unwrap();
let dir_str = dir.path().to_str().unwrap();
let output = std::process::Command::new(&exe)
.arg("--exact")
.arg("__crash_write_phase")
.env("CRASH_TEST_DIR", dir_str)
.env("CRASH_TEST_HOOK", "0")
.output()
.unwrap();
assert!(output.status.success() || output.status.code() == Some(0));
let target_bytes = std::fs::read(dir.path().join("targets.bin")).unwrap();
let targets: Vec<CidBytes> = target_bytes
.chunks_exact(36)
.map(|chunk| {
let mut cid = [0u8; 36];
cid.copy_from_slice(chunk);
cid
})
.collect();
if verify_phase(dir.path(), &targets) > 0 {
corrupted += 1;
}
});
assert_eq!(
corrupted, 0,
"{corrupted}/{total} iterations had refcount corruption after crash recovery"
);
});
}
#[test]
fn crash_with_slow_hook_preserves_refcounts() {
common::with_runtime(|| {
let mut corrupted = 0u32;
let total = 20u32;
(0..total).for_each(|_| {
let dir = tempfile::TempDir::new().unwrap();
let exe = std::env::current_exe().unwrap();
let dir_str = dir.path().to_str().unwrap();
let output = std::process::Command::new(&exe)
.arg("--exact")
.arg("__crash_write_phase")
.env("CRASH_TEST_DIR", dir_str)
.env("CRASH_TEST_HOOK", "1")
.output()
.unwrap();
assert!(output.status.success() || output.status.code() == Some(0));
let target_bytes = std::fs::read(dir.path().join("targets.bin")).unwrap();
let targets: Vec<CidBytes> = target_bytes
.chunks_exact(36)
.map(|chunk| {
let mut cid = [0u8; 36];
cid.copy_from_slice(chunk);
cid
})
.collect();
if verify_phase(dir.path(), &targets) > 0 {
corrupted += 1;
}
});
assert_eq!(
corrupted, 0,
"{corrupted}/{total} iterations had refcount corruption after crash with slow hook"
);
});
}
#[test]
fn __crash_write_phase() {
let dir = match std::env::var("CRASH_TEST_DIR") {
Ok(d) => d,
Err(_) => return,
};
let use_hook = std::env::var("CRASH_TEST_HOOK").map(|v| v == "1").unwrap_or(false);
let base = std::path::Path::new(&dir);
let rt = tokio::runtime::Runtime::new().unwrap();
let _guard = rt.enter();
let targets = write_phase(base, use_hook);
let target_bytes: Vec<u8> = targets.iter().flat_map(|cid| cid.iter().copied()).collect();
std::fs::write(base.join("targets.bin"), &target_bytes).unwrap();
unsafe { libc::_exit(0) }
}
+64
View File
@@ -104,6 +104,70 @@ pub fn compact_all_sealed(store: &TranquilBlockStore) {
});
}
pub fn tiny_blockstore_config(dir: &std::path::Path) -> BlockStoreConfig {
BlockStoreConfig {
data_dir: dir.join("data"),
index_dir: dir.join("index"),
max_file_size: 300,
group_commit: GroupCommitConfig {
checkpoint_interval_ms: 100,
checkpoint_write_threshold: 10,
..GroupCommitConfig::default()
},
shard_count: 1,
}
}
pub fn compact_by_liveness(store: &TranquilBlockStore) {
let liveness = store.compaction_liveness(0).unwrap();
liveness
.iter()
.filter(|(_, info)| info.total_blocks > 0 && info.ratio() < 0.99)
.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}"),
});
}
pub fn compact_lowest_liveness(store: &TranquilBlockStore) {
let liveness = store.compaction_liveness(0).unwrap();
let candidate = liveness
.iter()
.filter(|(_, info)| info.total_blocks > 0 && info.ratio() < 0.99)
.min_by(|(_, a), (_, b)| {
a.ratio()
.partial_cmp(&b.ratio())
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(&fid, _)| fid);
if let Some(fid) = candidate {
match store.compact_file(fid, 0) {
Ok(_) => {}
Err(tranquil_store::blockstore::CompactionError::ActiveFileCannotBeCompacted) => {}
Err(e) => eprintln!("compaction: {e}"),
}
}
}
pub fn collect_refcounts(store: &TranquilBlockStore, cids: &[CidBytes]) -> Vec<(u32, u32)> {
cids.iter()
.map(|cid| {
let seed = u32::from_le_bytes([cid[4], cid[5], cid[6], cid[7]]);
let rc = store
.block_index()
.get(cid)
.map(|e| e.refcount.raw())
.unwrap_or(0);
(seed, rc)
})
.collect()
}
pub struct TestStores {
pub blockstore: TranquilBlockStore,
pub eventlog: Arc<EventLog<RealIO>>,
@@ -0,0 +1,254 @@
mod common;
use std::collections::HashSet;
use tranquil_store::blockstore::{
BlockStoreConfig, CidBytes, GroupCommitConfig, TranquilBlockStore,
};
fn tiny_store_config(dir: &std::path::Path) -> BlockStoreConfig {
BlockStoreConfig {
data_dir: dir.join("data"),
index_dir: dir.join("index"),
max_file_size: 4096,
group_commit: GroupCommitConfig {
checkpoint_interval_ms: 600_000,
checkpoint_write_threshold: 1_000_000,
..GroupCommitConfig::default()
},
shard_count: 1,
}
}
fn make_block(seed: u32, size: usize) -> (CidBytes, Vec<u8>) {
(
common::test_cid(seed),
common::block_data(seed)
.into_iter()
.cycle()
.take(size)
.collect(),
)
}
fn verify_live_blocks(store: &TranquilBlockStore, live: &HashSet<u32>, context: &str) {
let missing: Vec<u32> = live
.iter()
.copied()
.filter(|&seed| {
store
.get_block_sync(&common::test_cid(seed))
.unwrap()
.is_none()
})
.collect();
assert!(
missing.is_empty(),
"{context}: {count} live blocks missing from store: {missing:?}",
count = missing.len(),
);
}
fn compact_sealed(store: &TranquilBlockStore) {
let files = store.list_data_files().unwrap();
files
.iter()
.copied()
.take(files.len().saturating_sub(1))
.for_each(|fid| {
let _ = store.compact_file(fid, 0);
});
}
fn delete_checkpoints(index_dir: &std::path::Path) {
let _ = std::fs::remove_file(index_dir.join("checkpoint_a.tqc"));
let _ = std::fs::remove_file(index_dir.join("checkpoint_b.tqc"));
}
#[test]
fn relocate_loses_refcount_on_hint_rebuild() {
common::with_runtime(|| {
let dir = tempfile::TempDir::new().unwrap();
let target = common::test_cid(1);
let target_data = vec![0xABu8; 200];
{
let store = TranquilBlockStore::open(tiny_store_config(dir.path())).unwrap();
store
.put_blocks_blocking(vec![(target, target_data.clone())])
.unwrap();
store
.put_blocks_blocking(vec![(target, target_data.clone())])
.unwrap();
let padding: Vec<_> = (100..130u32).map(|s| make_block(s, 300)).collect();
store.put_blocks_blocking(padding).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
compact_sealed(&store);
store.apply_commit_blocking(vec![], vec![target]).unwrap();
let data = store.get_block_sync(&target).unwrap();
assert!(data.is_some(), "target should be live, refcount 2 - 1 = 1");
}
delete_checkpoints(&dir.path().join("index"));
{
let store = TranquilBlockStore::open(tiny_store_config(dir.path())).unwrap();
let data = store.get_block_sync(&target).unwrap();
assert!(
data.is_some(),
"BUG: target missing after hint-only rebuild. \
RELOCATE created entry with refcount 1 instead of 2, \
then DEC brought it to 0 instead of 1."
);
std::thread::sleep(std::time::Duration::from_millis(10));
compact_sealed(&store);
let data = store.get_block_sync(&target).unwrap();
assert!(
data.is_some(),
"BUG: target removed by compaction after hint rebuild \
incorrectly set refcount to 0"
);
}
});
}
#[test]
fn multi_restart_with_compaction_between_put_and_dec() {
common::with_runtime(|| {
let dir = tempfile::TempDir::new().unwrap();
let shared = common::test_cid(42);
let shared_data = vec![0xCDu8; 200];
{
let store = TranquilBlockStore::open(tiny_store_config(dir.path())).unwrap();
store
.put_blocks_blocking(vec![(shared, shared_data.clone())])
.unwrap();
store
.put_blocks_blocking(vec![(shared, shared_data.clone())])
.unwrap();
store
.put_blocks_blocking(vec![(shared, shared_data.clone())])
.unwrap();
let filler: Vec<_> = (200..230u32).map(|s| make_block(s, 300)).collect();
store.put_blocks_blocking(filler).unwrap();
}
delete_checkpoints(&dir.path().join("index"));
{
let store = TranquilBlockStore::open(tiny_store_config(dir.path())).unwrap();
let data = store.get_block_sync(&shared).unwrap();
assert!(
data.is_some(),
"round 1: shared block present after rebuild"
);
std::thread::sleep(std::time::Duration::from_millis(10));
compact_sealed(&store);
store.apply_commit_blocking(vec![], vec![shared]).unwrap();
let data = store.get_block_sync(&shared).unwrap();
assert!(
data.is_some(),
"round 1: shared block should survive, refcount 3 - 1 = 2"
);
}
delete_checkpoints(&dir.path().join("index"));
{
let store = TranquilBlockStore::open(tiny_store_config(dir.path())).unwrap();
let data = store.get_block_sync(&shared).unwrap();
assert!(
data.is_some(),
"round 2: shared block should survive hint rebuild, refcount should be 2"
);
store.apply_commit_blocking(vec![], vec![shared]).unwrap();
let data = store.get_block_sync(&shared).unwrap();
assert!(
data.is_some(),
"round 2: shared block should survive DEC, refcount 2 - 1 = 1"
);
std::thread::sleep(std::time::Duration::from_millis(10));
compact_sealed(&store);
let data = store.get_block_sync(&shared).unwrap();
assert!(
data.is_some(),
"BUG: shared block removed by compaction. \
Multiple restarts with RELOCATE collapsed refcount \
from 3 down to 1, two DECs made it 0."
);
}
});
}
#[test]
fn stress_create_delete_restart_cycle_matches_bug_report() {
common::with_runtime(|| {
let dir = tempfile::TempDir::new().unwrap();
let mut live: HashSet<u32> = HashSet::new();
let mut rng = common::Rng::new(12345);
let mut next_seed: u32 = 0;
(0..4).for_each(|cycle| {
{
let store = TranquilBlockStore::open(tiny_store_config(dir.path())).unwrap();
(0..20).for_each(|_| {
let seed_a = next_seed;
let seed_b = next_seed + 1;
next_seed += 2;
store
.put_blocks_blocking(vec![make_block(seed_a, 150), make_block(seed_b, 150)])
.unwrap();
live.insert(seed_a);
live.insert(seed_b);
if rng.next_u32() % 2 == 0 {
let victim: Option<u32> = live.iter().copied().next();
if let Some(v) = victim {
store
.apply_commit_blocking(vec![], vec![common::test_cid(v)])
.unwrap();
live.remove(&v);
}
}
});
std::thread::sleep(std::time::Duration::from_millis(10));
compact_sealed(&store);
verify_live_blocks(&store, &live, &format!("cycle {cycle} before kill"));
}
delete_checkpoints(&dir.path().join("index"));
{
let store = TranquilBlockStore::open(tiny_store_config(dir.path())).unwrap();
verify_live_blocks(&store, &live, &format!("cycle {cycle} after hint rebuild"));
}
});
});
}
@@ -0,0 +1,567 @@
mod common;
use std::path::Path;
use std::sync::Arc;
use common::{
collect_refcounts, compact_by_liveness, compact_lowest_liveness, test_cid,
tiny_blockstore_config, with_runtime,
};
use tranquil_store::RealIO;
use tranquil_store::blockstore::{CidBytes, TranquilBlockStore};
use tranquil_store::eventlog::{EventLog, EventLogBridge, EventLogConfig};
use tranquil_store::metastore::handler::HandlerPool;
use tranquil_store::metastore::partitions::Partition;
use tranquil_store::metastore::{Metastore, MetastoreConfig};
struct FullStack {
blockstore: TranquilBlockStore,
_pool: Arc<HandlerPool>,
_event_log: Arc<EventLog<RealIO>>,
}
fn open_full_stack(base_dir: &Path) -> FullStack {
let metastore_dir = base_dir.join("metastore");
let segments_dir = base_dir.join("eventlog").join("segments");
let blockstore_data = base_dir.join("blockstore").join("data");
let blockstore_index = base_dir.join("blockstore").join("index");
[&metastore_dir, &segments_dir, &blockstore_data, &blockstore_index]
.iter()
.for_each(|d| std::fs::create_dir_all(d).unwrap());
let metastore = Metastore::open(&metastore_dir, MetastoreConfig::default()).unwrap();
let blockstore = TranquilBlockStore::open(tranquil_store::blockstore::BlockStoreConfig {
data_dir: blockstore_data,
index_dir: blockstore_index,
max_file_size: 512,
group_commit: tranquil_store::blockstore::GroupCommitConfig::default(),
shard_count: 1,
})
.unwrap();
let event_log = Arc::new(
EventLog::open(
EventLogConfig {
segments_dir,
..EventLogConfig::default()
},
RealIO::new(),
)
.unwrap(),
);
let bridge = Arc::new(EventLogBridge::new(Arc::clone(&event_log)));
let was_clean = tranquil_store::consistency::had_clean_shutdown(base_dir);
tranquil_store::consistency::remove_clean_shutdown_marker(base_dir).ok();
let indexes = metastore.partition(Partition::Indexes).clone();
let event_ops = metastore.event_ops(Arc::clone(&bridge));
let recovered = event_ops
.recover_metastore_mutations(&indexes)
.unwrap();
if recovered > 0 {
eprintln!("replayed {recovered} metastore mutations from eventlog");
}
if !was_clean || recovered > 0 {
let report = tranquil_store::consistency::verify_store_consistency(
&blockstore,
&metastore,
&event_log,
);
report.log_findings();
if report.has_repairable_issues() {
let repair = tranquil_store::consistency::repair_known_issues(&blockstore, &report);
if repair.orphan_files_removed > 0 {
eprintln!("removed {} orphan files", repair.orphan_files_removed);
}
}
}
let pool = Arc::new(HandlerPool::spawn::<RealIO>(
metastore,
bridge,
Some(blockstore.clone()),
None,
));
FullStack {
blockstore,
_pool: pool,
_event_log: event_log,
}
}
fn close_full_stack(stack: FullStack, base_dir: &Path) {
let rt = tokio::runtime::Handle::current();
rt.block_on(stack._pool.close());
if let Err(e) = stack._event_log.shutdown() {
eprintln!("eventlog shutdown: {e}");
}
tranquil_store::consistency::write_clean_shutdown_marker(base_dir).ok();
drop(stack.blockstore);
}
fn verify_blocks_and_refcounts(
store: &TranquilBlockStore,
live_cids: &[CidBytes],
expected_refcounts: Option<&[(u32, u32)]>,
label: &str,
) {
let missing: Vec<u32> = live_cids
.iter()
.filter(|cid| store.get_block_sync(cid).unwrap().is_none())
.map(|cid| u32::from_le_bytes([cid[4], cid[5], cid[6], cid[7]]))
.collect();
assert!(
missing.is_empty(),
"{label}: live blocks missing after reopen: {missing:?}"
);
match expected_refcounts {
Some(expected) => {
let actual = collect_refcounts(store, live_cids);
let mismatches: Vec<_> = expected
.iter()
.zip(actual.iter())
.filter(|((_, exp_rc), (_, act_rc))| exp_rc != act_rc)
.map(|((seed, exp), (_, act))| format!("seed {seed}: before={exp} after={act}"))
.collect();
assert!(
mismatches.is_empty(),
"{label}: refcounts changed across reopen:\n{}",
mismatches.join("\n"),
);
}
None => {
live_cids.iter().for_each(|cid| {
let rc = store
.block_index()
.get(cid)
.map(|e| e.refcount.raw())
.unwrap_or(0);
assert!(
rc > 0,
"{label}: refcount dropped to 0 for seed {}",
u32::from_le_bytes([cid[4], cid[5], cid[6], cid[7]])
);
});
}
}
}
#[test]
fn hundreds_of_compaction_cycles() {
with_runtime(|| {
let dir = tempfile::TempDir::new().unwrap();
let live_cids: Vec<CidBytes> = (0..15u32).map(test_cid).collect();
{
let store = TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
live_cids.iter().for_each(|cid| {
store
.put_blocks_blocking(vec![(*cid, vec![0xAA; 80])])
.unwrap();
});
(0..500u32).for_each(|round| {
let churn = test_cid(2000 + round);
store
.put_blocks_blocking(vec![(churn, vec![0xDD; 80])])
.unwrap();
store.apply_commit_blocking(vec![], vec![churn]).unwrap();
if round % 3 == 0 {
compact_lowest_liveness(&store);
}
});
(0..200).for_each(|_| compact_by_liveness(&store));
live_cids.iter().for_each(|cid| {
assert!(
store.get_block_sync(cid).unwrap().is_some(),
"sanity: block present before drop"
);
});
drop(store);
}
let store = TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
verify_blocks_and_refcounts(&store, &live_cids, None, "500 churn + 200 compact rounds");
});
}
#[test]
fn commit_style_decrements() {
with_runtime(|| {
let dir = tempfile::TempDir::new().unwrap();
let shared_nodes: Vec<CidBytes> = (0..10u32).map(test_cid).collect();
let refcounts_before = {
let store = TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
shared_nodes.iter().for_each(|cid| {
store
.put_blocks_blocking(vec![(*cid, vec![0xAA; 80])])
.unwrap();
});
let mut prev_commit = test_cid(5000);
store
.put_blocks_blocking(vec![(prev_commit, vec![0xCC; 80])])
.unwrap();
(0..500u32).for_each(|round| {
let new_commit = test_cid(5001 + round);
let new_mst_node = test_cid(6000 + round);
let old_mst_node = test_cid(7000 + round);
store
.put_blocks_blocking(vec![
(new_commit, vec![0xBB; 80]),
(new_mst_node, vec![0xCC; 60]),
(old_mst_node, vec![0xDD; 60]),
])
.unwrap();
store
.apply_commit_blocking(vec![], vec![prev_commit, old_mst_node])
.unwrap();
if round > 0 {
let prev_mst = test_cid(6000 + round - 1);
store
.apply_commit_blocking(vec![], vec![prev_mst])
.unwrap();
}
prev_commit = new_commit;
if round % 2 == 0 {
compact_lowest_liveness(&store);
}
});
(0..300).for_each(|_| {
compact_by_liveness(&store);
std::thread::sleep(std::time::Duration::from_millis(1));
});
shared_nodes.iter().for_each(|cid| {
assert!(
store.get_block_sync(cid).unwrap().is_some(),
"sanity: shared node present before drop"
);
});
let rc = collect_refcounts(&store, &shared_nodes);
drop(store);
rc
};
let store = TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
verify_blocks_and_refcounts(
&store,
&shared_nodes,
Some(&refcounts_before),
"500 commits + 300 compact rounds",
);
});
}
#[test]
fn extreme_file_churn_with_dedup_hits() {
with_runtime(|| {
let dir = tempfile::TempDir::new().unwrap();
let live_cids: Vec<CidBytes> = (0..8u32).map(test_cid).collect();
let live_data: Vec<u8> = vec![0xAA; 80];
let refcounts_before = {
let store = TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
live_cids.iter().for_each(|cid| {
store
.put_blocks_blocking(vec![(*cid, live_data.clone())])
.unwrap();
});
(0..300u32).for_each(|round| {
let churn = test_cid(3000 + round);
store
.put_blocks_blocking(vec![(churn, vec![0xEE; 80])])
.unwrap();
store.apply_commit_blocking(vec![], vec![churn]).unwrap();
if round % 50 == 0 {
live_cids.iter().for_each(|cid| {
store
.put_blocks_blocking(vec![(*cid, live_data.clone())])
.unwrap();
});
}
if round % 2 == 0 {
compact_lowest_liveness(&store);
}
});
(0..200).for_each(|_| compact_by_liveness(&store));
let rc = collect_refcounts(&store, &live_cids);
drop(store);
rc
};
let store = TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
verify_blocks_and_refcounts(
&store,
&live_cids,
Some(&refcounts_before),
"300 churn + dedup re-puts + 200 compacts",
);
});
}
#[test]
fn long_idle_compaction_only_phase() {
with_runtime(|| {
let dir = tempfile::TempDir::new().unwrap();
let live_cids: Vec<CidBytes> = (0..20u32).map(test_cid).collect();
{
let store = TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
live_cids.iter().for_each(|cid| {
store
.put_blocks_blocking(vec![(*cid, vec![0xAA; 80])])
.unwrap();
});
(0..100u32).for_each(|round| {
let churn = test_cid(4000 + round);
store
.put_blocks_blocking(vec![(churn, vec![0xFF; 80])])
.unwrap();
store.apply_commit_blocking(vec![], vec![churn]).unwrap();
});
(0..500).for_each(|_| {
compact_by_liveness(&store);
std::thread::sleep(std::time::Duration::from_millis(1));
});
live_cids.iter().for_each(|cid| {
assert!(
store.get_block_sync(cid).unwrap().is_some(),
"sanity: block present before drop"
);
});
drop(store);
}
let store = TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
verify_blocks_and_refcounts(
&store,
&live_cids,
None,
"idle with 500 compaction-only rounds",
);
});
}
#[test]
fn multiple_restart_cycles_blockstore() {
with_runtime(|| {
let dir = tempfile::TempDir::new().unwrap();
let live_cids: Vec<CidBytes> = (0..10u32).map(test_cid).collect();
{
let store = TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
live_cids.iter().for_each(|cid| {
store
.put_blocks_blocking(vec![(*cid, vec![0xAA; 80])])
.unwrap();
});
(0..50u32).for_each(|round| {
let churn = test_cid(8000 + round);
store
.put_blocks_blocking(vec![(churn, vec![0xBB; 80])])
.unwrap();
store.apply_commit_blocking(vec![], vec![churn]).unwrap();
});
drop(store);
}
(0..10u32).for_each(|cycle| {
{
let store =
TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
(0..50u32).for_each(|round| {
let churn = test_cid(9000 + cycle * 100 + round);
store
.put_blocks_blocking(vec![(churn, vec![0xCC; 80])])
.unwrap();
store.apply_commit_blocking(vec![], vec![churn]).unwrap();
compact_lowest_liveness(&store);
});
(0..50).for_each(|_| compact_by_liveness(&store));
live_cids.iter().for_each(|cid| {
assert!(
store.get_block_sync(cid).unwrap().is_some(),
"cycle {cycle}: block missing before drop"
);
});
drop(store);
}
let store = TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap();
verify_blocks_and_refcounts(
&store,
&live_cids,
None,
&format!("blockstore restart cycle {cycle}"),
);
});
});
}
#[test]
fn full_stack_compaction_restart_preserves_refcounts() {
with_runtime(|| {
let base = tempfile::TempDir::new().unwrap();
let base_dir = base.path().to_path_buf();
let live_cids: Vec<CidBytes> = (0..15u32).map(test_cid).collect();
let refcounts_before = {
let stack = open_full_stack(&base_dir);
live_cids.iter().for_each(|cid| {
stack
.blockstore
.put_blocks_blocking(vec![(*cid, vec![0xAA; 80])])
.unwrap();
});
(0..500u32).for_each(|round| {
let churn = test_cid(2000 + round);
stack
.blockstore
.put_blocks_blocking(vec![(churn, vec![0xDD; 80])])
.unwrap();
stack
.blockstore
.apply_commit_blocking(vec![], vec![churn])
.unwrap();
if round % 3 == 0 {
compact_lowest_liveness(&stack.blockstore);
}
});
(0..200).for_each(|_| compact_by_liveness(&stack.blockstore));
let rc = collect_refcounts(&stack.blockstore, &live_cids);
live_cids.iter().for_each(|cid| {
assert!(
stack.blockstore.get_block_sync(cid).unwrap().is_some(),
"sanity: block present before shutdown"
);
});
close_full_stack(stack, &base_dir);
rc
};
let stack = open_full_stack(&base_dir);
verify_blocks_and_refcounts(
&stack.blockstore,
&live_cids,
Some(&refcounts_before),
"full stack restart",
);
close_full_stack(stack, &base_dir);
});
}
#[test]
fn full_stack_multiple_restart_cycles() {
with_runtime(|| {
let base = tempfile::TempDir::new().unwrap();
let base_dir = base.path().to_path_buf();
let live_cids: Vec<CidBytes> = (0..10u32).map(test_cid).collect();
{
let stack = open_full_stack(&base_dir);
live_cids.iter().for_each(|cid| {
stack
.blockstore
.put_blocks_blocking(vec![(*cid, vec![0xAA; 80])])
.unwrap();
});
close_full_stack(stack, &base_dir);
}
(0..10u32).for_each(|cycle| {
let refcounts_before = {
let stack = open_full_stack(&base_dir);
(0..50u32).for_each(|round| {
let churn = test_cid(5000 + cycle * 100 + round);
stack
.blockstore
.put_blocks_blocking(vec![(churn, vec![0xBB; 80])])
.unwrap();
stack
.blockstore
.apply_commit_blocking(vec![], vec![churn])
.unwrap();
compact_lowest_liveness(&stack.blockstore);
});
(0..30).for_each(|_| compact_by_liveness(&stack.blockstore));
let rc = collect_refcounts(&stack.blockstore, &live_cids);
live_cids.iter().for_each(|cid| {
assert!(
stack.blockstore.get_block_sync(cid).unwrap().is_some(),
"cycle {cycle}: block missing before shutdown"
);
});
close_full_stack(stack, &base_dir);
rc
};
let stack = open_full_stack(&base_dir);
verify_blocks_and_refcounts(
&stack.blockstore,
&live_cids,
Some(&refcounts_before),
&format!("full stack cycle {cycle}"),
);
close_full_stack(stack, &base_dir);
});
});
}
@@ -268,12 +268,12 @@ async fn mst_create_update_delete_with_refcounts() {
assert_eq!(&retrieved_a_v2.unwrap()[..], &record_a_v2);
assert!(
store.has(&cid_a_v1).await.unwrap(),
"cid_a_v1 should still exist, tombstoned but not GC'd"
store.get(&cid_a_v1).await.unwrap().is_some(),
"cid_a_v1 data should still exist, tombstoned but not GC'd"
);
assert!(
store.has(&cid_b).await.unwrap(),
"cid_b should still exist, tombstoned but not GC'd"
store.get(&cid_b).await.unwrap().is_some(),
"cid_b data should still exist, tombstoned but not GC'd"
);
assert!(
@@ -284,8 +284,8 @@ async fn mst_create_update_delete_with_refcounts() {
assert_eq!(&retrieved_c[..], &record_c);
assert!(
store.has(&cid_shared).await.unwrap(),
"shared-content block should still exist, tombstoned but not GC'd"
store.get(&cid_shared).await.unwrap().is_some(),
"shared-content block data should still exist, tombstoned but not GC'd"
);
let loaded_mst = Mst::load(storage.clone(), mst_root_v2, None);
@@ -0,0 +1,217 @@
mod common;
use std::sync::Arc;
use cid::Cid;
use common::{compact_by_liveness, tiny_blockstore_config};
use jacquard_repo::mst::Mst;
use jacquard_repo::storage::BlockStore;
use tranquil_store::blockstore::TranquilBlockStore;
fn cid_to_fixed(cid: &Cid) -> [u8; 36] {
let bytes = cid.to_bytes();
let mut arr = [0u8; 36];
arr.copy_from_slice(&bytes[..36]);
arr
}
fn make_record_bytes(seed: u32) -> Vec<u8> {
serde_ipld_dagcbor::to_vec(&serde_json::json!({
"$type": "app.bsky.feed.post",
"text": format!("record {seed}"),
"createdAt": "2026-01-01T00:00:00Z"
}))
.unwrap()
}
fn make_fake_commit_cid(counter: u32) -> Cid {
let data = format!("commit-{counter}");
let mh = multihash::Multihash::wrap(0x12, &{
use sha2::Digest;
sha2::Sha256::digest(data.as_bytes())
})
.unwrap();
Cid::new_v1(0x71, mh)
}
async fn compute_obsolete_from_diff<S: BlockStore + Sync + Send + 'static>(
old_mst: &Mst<S>,
new_mst: &Mst<S>,
old_commit_cid: Cid,
) -> Vec<Cid> {
let diff = old_mst.diff(new_mst).await.unwrap();
std::iter::once(old_commit_cid)
.chain(diff.removed_mst_blocks.into_iter())
.chain(diff.removed_cids.into_iter())
.collect()
}
#[tokio::test]
async fn mst_shared_subtrees_survive_incremental_writes_compaction_restart() {
let dir = tempfile::TempDir::new().unwrap();
let mut commit_counter = 0u32;
let final_node_cids: Vec<Cid>;
{
let store = Arc::new(TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap());
let mut mst = Mst::new(store.clone());
let mut root: Option<Cid> = None;
let mut prev_commit = make_fake_commit_cid(commit_counter);
commit_counter += 1;
for i in 0..30u32 {
let record_bytes = make_record_bytes(i);
let record_cid = store.put(&record_bytes).await.unwrap();
let key = format!("app.bsky.feed.post/{i:06}");
mst = match root {
None => mst.add(&key, record_cid).await.unwrap(),
Some(r) => {
let loaded = Mst::load(store.clone(), r, None);
loaded.add(&key, record_cid).await.unwrap()
}
};
let new_root = mst.persist().await.unwrap();
if let Some(old_root) = root {
let old_settled = Mst::load(store.clone(), old_root, None);
let new_settled = Mst::load(store.clone(), new_root, None);
let obsolete =
compute_obsolete_from_diff(&old_settled, &new_settled, prev_commit).await;
let obsolete_fixed: Vec<[u8; 36]> =
obsolete.iter().map(|c| cid_to_fixed(c)).collect();
let s = store.clone();
tokio::task::spawn_blocking(move || {
s.apply_commit_blocking(vec![], obsolete_fixed).unwrap();
})
.await
.unwrap();
}
root = Some(new_root);
prev_commit = make_fake_commit_cid(commit_counter);
commit_counter += 1;
if i % 5 == 0 {
let s = store.clone();
tokio::task::spawn_blocking(move || compact_by_liveness(&s))
.await
.unwrap();
}
}
for i in 0..15u32 {
let record_bytes = make_record_bytes(1000 + i);
let record_cid = store.put(&record_bytes).await.unwrap();
let key = format!("app.bsky.feed.like/{i:06}");
let loaded = Mst::load(store.clone(), root.unwrap(), None);
mst = loaded.add(&key, record_cid).await.unwrap();
let new_root = mst.persist().await.unwrap();
let old_settled = Mst::load(store.clone(), root.unwrap(), None);
let new_settled = Mst::load(store.clone(), new_root, None);
let obsolete =
compute_obsolete_from_diff(&old_settled, &new_settled, prev_commit).await;
let obsolete_fixed: Vec<[u8; 36]> =
obsolete.iter().map(|c| cid_to_fixed(c)).collect();
let s = store.clone();
tokio::task::spawn_blocking(move || {
s.apply_commit_blocking(vec![], obsolete_fixed).unwrap();
})
.await
.unwrap();
root = Some(new_root);
prev_commit = make_fake_commit_cid(commit_counter);
commit_counter += 1;
if i % 3 == 0 {
let s = store.clone();
tokio::task::spawn_blocking(move || compact_by_liveness(&s))
.await
.unwrap();
}
}
let final_settled = Mst::load(store.clone(), root.unwrap(), None);
final_node_cids = final_settled.collect_node_cids().await.unwrap();
final_node_cids.iter().for_each(|cid| {
let fixed = cid_to_fixed(cid);
let rc = store.block_index().get(&fixed).map(|e| e.refcount.raw());
assert!(
rc.is_some_and(|r| r > 0),
"MST node {cid} has refcount {rc:?} before shutdown"
);
});
let s = store.clone();
tokio::task::spawn_blocking(move || {
(0..100).for_each(|_| compact_by_liveness(&s));
})
.await
.unwrap();
final_node_cids.iter().for_each(|cid| {
let fixed = cid_to_fixed(cid);
let block = store.get_block_sync(&fixed).unwrap();
assert!(
block.is_some(),
"MST node {cid} missing after compaction before shutdown"
);
});
drop(store);
}
{
let store = Arc::new(
TranquilBlockStore::open(tiny_blockstore_config(dir.path())).unwrap(),
);
let missing: Vec<String> = final_node_cids
.iter()
.filter_map(|cid| {
let fixed = cid_to_fixed(cid);
match store.get_block_sync(&fixed) {
Ok(Some(_)) => None,
Ok(None) => {
let rc = store.block_index().get(&fixed).map(|e| e.refcount.raw());
Some(format!("{cid} missing, index refcount {rc:?}"))
}
Err(e) => Some(format!("{cid} error: {e}")),
}
})
.collect();
assert!(
missing.is_empty(),
"{} of {} MST nodes missing after reopen:\n{}",
missing.len(),
final_node_cids.len(),
missing.join("\n"),
);
let refcount_issues: Vec<String> = final_node_cids
.iter()
.filter_map(|cid| {
let fixed = cid_to_fixed(cid);
let rc = store.block_index().get(&fixed).map(|e| e.refcount.raw());
match rc {
Some(0) => Some(format!("{cid} refcount dropped to 0")),
None => Some(format!("{cid} not in index")),
_ => None,
}
})
.collect();
assert!(
refcount_issues.is_empty(),
"MST nodes with bad refcounts after reopen:\n{}",
refcount_issues.join("\n"),
);
}
}
@@ -75,7 +75,7 @@ impl SimHarness {
let data = vec![seed as u8; data_size];
let loc = writer.append_block(&cid, &data).unwrap();
hint_writer
.append_hint(&cid, loc.file_id, loc.offset, loc.length)
.append_hint(&cid, &loc)
.unwrap();
(cid, loc)
})
@@ -112,7 +112,7 @@ impl SimHarness {
HintOffset::new(entries.len() as u64 * HINT_RECORD_SIZE as u64),
);
index
.write_checkpoint(CommitEpoch::zero(), &positions)
.write_checkpoint_with_positions(CommitEpoch::zero(), &positions)
.unwrap();
}
@@ -539,7 +539,7 @@ fn sim_aggressive_faults_data_integrity() {
let data = vec![i as u8; 64];
let loc = writer.append_block(&cid, &data).ok()?;
hint_writer
.append_hint(&cid, loc.file_id, loc.offset, loc.length)
.append_hint(&cid, &loc)
.ok()?;
Some(())
})?;
+28 -12
View File
@@ -142,6 +142,17 @@ pub async fn get_repo(
return get_repo_since(&state, &did, &head_cid, since).await;
}
let _permit = match state.repo_export_semaphore.try_acquire() {
Ok(permit) => permit,
Err(_) => {
return (
StatusCode::SERVICE_UNAVAILABLE,
"Too many concurrent repo exports",
)
.into_response();
}
};
let car_bytes = match generate_repo_car_from_user_blocks(
state.repos.repo.as_ref(),
&state.block_store,
@@ -213,19 +224,24 @@ async fn get_repo_since(state: &AppState, did: &Did, head_cid: &Cid, since: &str
.into_response();
}
let blocks = match state.block_store.get_many(&block_cids).await {
Ok(b) => b,
Err(e) => {
error!("Block store error in get_repo_since: {:?}", e);
return ApiError::InternalError(Some("Failed to get blocks".into())).into_response();
}
};
for chunk_start in (0..block_cids.len()).step_by(500) {
let chunk_end = (chunk_start + 500).min(block_cids.len());
let chunk = &block_cids[chunk_start..chunk_end];
let blocks = match state.block_store.get_many(chunk).await {
Ok(b) => b,
Err(e) => {
error!("Block store error in get_repo_since: {:?}", e);
return ApiError::InternalError(Some("Failed to get blocks".into()))
.into_response();
}
};
blocks
.into_iter()
.enumerate()
.filter_map(|(i, block_opt)| block_opt.map(|block| (block_cids[i], block)))
.for_each(|(cid, block)| car_bytes.extend_from_slice(&encode_car_block(&cid, &block)));
chunk
.iter()
.zip(blocks.into_iter())
.filter_map(|(cid, block_opt)| block_opt.map(|block| (*cid, block)))
.for_each(|(cid, block)| car_bytes.extend_from_slice(&encode_car_block(&cid, &block)));
}
(
StatusCode::OK,
+2 -1
View File
@@ -7,7 +7,8 @@ Pod=tranquil-pds.pod
Environment=POSTGRES_USER=tranquil_pds
Environment=POSTGRES_DB=pds
Secret=tranquil-pds-db-password,type=env,target=POSTGRES_PASSWORD
Volume=/srv/tranquil-pds/postgres:/var/lib/postgresql/data:Z
# In memory of @cute.kyu.re when Lewis forgot to remove "/data" here after removing it from the compose file and another account was tragically lost :(
Volume=/srv/tranquil-pds/postgres:/var/lib/postgresql:Z
HealthCmd=pg_isready -U tranquil_pds -d pds
HealthInterval=10s
HealthTimeout=5s
+7 -6
View File
@@ -49,12 +49,12 @@ mkdir -p /var/lib/tranquil/blobs
We'll set ownership after creating the service user.
## Install deno (for frontend build)
## Install Node.js and pnpm (for frontend build)
```bash
curl -fsSL https://deno.land/install.sh | sh
export PATH="$HOME/.deno/bin:$PATH"
echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.bashrc
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt install -y nodejs
npm install -g pnpm
```
## Clone and build Tranquil PDS
@@ -64,7 +64,8 @@ cd /opt
git clone https://tangled.org/tranquil.farm/tranquil-pds tranquil-pds
cd tranquil-pds
cd frontend
deno task build
pnpm install --frozen-lockfile
pnpm build
cd ..
cargo build --release
```
@@ -330,7 +331,7 @@ Update Tranquil PDS:
```bash
cd /opt/tranquil-pds
git pull
cd frontend && deno task build && cd ..
cd frontend && pnpm install --frozen-lockfile && pnpm build && cd ..
cargo build --release
systemctl stop tranquil-pds
cp target/release/tranquil-pds /usr/local/bin/
Generated
+4 -21
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1766314097,
"narHash": "sha256-laJftWbghBehazn/zxVJ8NdENVgjccsWAdAqKXhErrM=",
"lastModified": 1775888245,
"narHash": "sha256-nwASzrRDD1JBEu/o8ekKYEXm/oJW6EMCzCRdrwcLe90=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "306ea70f9eb0fb4e040f8540e2deab32ed7e2055",
"rev": "13043924aaa7375ce482ebe2494338e058282925",
"type": "github"
},
"original": {
@@ -16,26 +16,9 @@
"type": "github"
}
},
"nixpkgs-fetch-deno": {
"locked": {
"lastModified": 1766410835,
"narHash": "sha256-dRhVt0aFDyTqppyzRLxiO1JZEAoIA2fUnaeyJTe+UwU=",
"owner": "aMOPel",
"repo": "nixpkgs",
"rev": "c9801acc8c4fac6377d076bc1c102b15bd9cfa6f",
"type": "github"
},
"original": {
"owner": "aMOPel",
"ref": "feat/fetchDenoDeps",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"nixpkgs-fetch-deno": "nixpkgs-fetch-deno"
"nixpkgs": "nixpkgs"
}
}
},
+42 -40
View File
@@ -1,51 +1,53 @@
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
# tranquil frontend uses deno as its package manager and build time runtime.
# nixpkgs does not have deno support yet but its being worked on in https://github.com/NixOS/nixpkgs/pull/419255
# for now we important that PR as well purely for its fetchDenoDeps
nixpkgs-fetch-deno.url = "github:aMOPel/nixpkgs/feat/fetchDenoDeps";
};
outputs = {
self,
nixpkgs,
...
} @ inputs: let
forAllSystems = function:
nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed (
system: (function system nixpkgs.legacyPackages.${system})
);
in {
packages = forAllSystems (system: pkgs: {
tranquil-pds = pkgs.callPackage ./default.nix {};
tranquil-frontend = pkgs.callPackage ./frontend.nix {
inherit (inputs.nixpkgs-fetch-deno.legacyPackages.${system}) fetchDenoDeps;
outputs =
{
self,
nixpkgs,
}:
let
forAllSystems =
function:
nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed (
system: function nixpkgs.legacyPackages.${system}
);
in
{
packages = forAllSystems (pkgs: {
tranquil-pds = pkgs.callPackage ./default.nix { };
tranquil-frontend = pkgs.callPackage ./frontend.nix { };
default = self.packages.${pkgs.stdenv.hostPlatform.system}.tranquil-pds;
});
devShells = forAllSystems (pkgs: {
default = pkgs.callPackage ./shell.nix { };
});
nixosModules = {
default = self.nixosModules.tranquil-pds;
tranquil-pds =
{ lib, pkgs, ... }:
{
_file = "${self.outPath}/flake.nix#nixosModules.tranquil-pds";
imports = [ ./module.nix ];
config.services.tranquil-pds = {
package = self.packages.${pkgs.stdenv.hostPlatform.system}.tranquil-pds;
settings.frontend.package = self.packages.${pkgs.stdenv.hostPlatform.system}.tranquil-frontend;
};
};
};
default = self.packages.${pkgs.stdenv.hostPlatform.system}.tranquil-pds;
});
devShells = forAllSystems (system: pkgs: {
default = pkgs.callPackage ./shell.nix {};
});
checks.x86_64-linux.integration = import ./test.nix {
pkgs = nixpkgs.legacyPackages.x86_64-linux;
inherit self;
};
nixosModules = {
default = self.nixosModules.tranquil-pds;
tranquil-pds = {
_file = "${self.outPath}/flake.nix#nixosModules.tranquil-pds";
imports = [(import ./module.nix self)];
checks.aarch64-linux.integration = import ./test.nix {
pkgs = nixpkgs.legacyPackages.aarch64-linux;
inherit self;
};
};
checks.x86_64-linux.integration = import ./test.nix {
pkgs = nixpkgs.legacyPackages.x86_64-linux;
inherit self;
};
checks.aarch64-linux.integration = import ./test.nix {
pkgs = nixpkgs.legacyPackages.aarch64-linux;
inherit self;
};
};
}
+43 -60
View File
@@ -1,66 +1,49 @@
{
lib,
stdenvNoCC,
fetchDenoDeps,
fetchFromGitHub,
buildGoModule,
deno,
esbuild,
}: let
nodejs,
pnpm,
pnpmConfigHook,
fetchPnpmDeps,
nix-update-script,
}:
let
toml = (lib.importTOML ./Cargo.toml).workspace.package;
deno-deps = fetchDenoDeps {
pname = "tranquil-frontend-deno-deps";
denoLock = ./frontend/deno.lock;
hash = "sha256-UB+E00TjWX0fTUZ7XwcwRJ/OUOSSJpz6Ss04U5i8dGI=";
};
# the esbuild in upstream nixpkgs is too old.
esbuild' = esbuild.override {
buildGoModule = args: buildGoModule (
args // (
let
version = "0.27.2";
in {
inherit version;
src = fetchFromGitHub {
owner = "evanw";
repo = "esbuild";
tag = "v${version}";
hash = "sha256-JbJB3F1NQlmA5d0rdsLm4RVD24OPdV4QXpxW8VWbESA";
};
vendorHash = "sha256-+BfxCyg0KkDQpHt/wycy/8CTG6YBA/VJvJFhhzUnSiQ";
}
)
);
};
in stdenvNoCC.mkDerivation {
pname = "tranquil-frontend";
inherit (toml) version;
src = ./frontend;
in
stdenvNoCC.mkDerivation (finalAttrs: {
pname = "tranquil-frontend";
inherit (toml) version;
nativeBuildInputs = [
deno
src = ./frontend;
pnpmDeps = fetchPnpmDeps {
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
hash = "sha256-E0S8dOaTOpY9m7Ft59tUQ6CLlLriWPE4WE1+S45vomY=";
};
nativeBuildInputs = [
pnpm
nodejs
pnpmConfigHook
];
buildPhase = ''
runHook preBuild
pnpm build
runHook postBuild
'';
installPhase = ''
runHook preInstall
cp -r ./dist $out
runHook postInstall
'';
passthru.updateScript = nix-update-script {
extraArgs = [
"--version"
"SKIP"
];
# tell vite (through the esbuild api) where the nix provided esbuild binary is
env.ESBUILD_BINARY_PATH = lib.getExe esbuild';
buildPhase = ''
# copy the deps to the required location
cp -r --no-preserve=mode ${deno-deps.denoDeps}/.deno ./
cp -r --no-preserve=mode ${deno-deps.denoDeps}/vendor ./
pwd
ls /build/frontend/vendor
# Now you can run the project using deps
# you need to activate [deno's vendor feature](https://docs.deno.com/runtime/fundamentals/modules/#vendoring-remote-modules)
# you need to use the `$DENO_DIR` env var, to point deno to the correct local cache
DENO_DIR=./.deno deno run --frozen --cached-only build
'';
installPhase = ''
cp -r ./dist $out
'';
}
};
})
+5 -2
View File
@@ -1,7 +1,10 @@
FROM denoland/deno:alpine AS builder
FROM node:24-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . ./
RUN deno task build
RUN pnpm build
FROM nginx:1.29-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
-22
View File
@@ -1,22 +0,0 @@
{
"tasks": {
"dev": "deno run -A npm:vite",
"build": "deno run -A npm:vite build",
"preview": "deno run -A npm:vite preview",
"check": "deno run -A npm:svelte-check --tsconfig ./tsconfig.json",
"test": "deno run -A npm:vitest",
"test:run": "deno run -A npm:vitest run",
"test:watch": "deno run -A npm:vitest watch",
"test:ui": "deno run -A npm:vitest --ui",
"test:coverage": "deno run -A npm:vitest run --coverage"
},
"nodeModulesDir": "auto",
"lint": {
"rules": {
"exclude": [
"require-await",
"prefer-const"
]
}
}
}
-1930
View File
File diff suppressed because it is too large Load Diff
+2475
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts" module>
const EXAMPLE_HANDLES = [
"nel.pet",
"lewis.moe",
"oyster.cafe",
"llaama.bsky.social",
"debugman.wizardry.systems",
"nonbinary.computer",
@@ -15,9 +15,10 @@
session: Session
hasPassword: boolean
onPasskeysChanged?: (count: number) => void
onReauthRequired: (methods: string[], retryAction: () => Promise<void>) => void
}
let { session, hasPassword, onPasskeysChanged }: Props = $props()
let { session, hasPassword, onPasskeysChanged, onReauthRequired }: Props = $props()
interface Passkey {
id: string
@@ -81,6 +82,14 @@
}
}
function handleReauthError(e: unknown, fallback: string, retryAction: () => Promise<void>) {
if (e instanceof ApiError && e.error === 'ReauthRequired') {
onReauthRequired(e.reauthMethods || ['password'], retryAction)
} else {
toast.error(e instanceof ApiError ? e.message : fallback)
}
}
async function handleDeletePasskey(id: string) {
const passkey = passkeys.find(p => p.id === id)
if (!confirm($_('security.deletePasskeyConfirm', { values: { name: passkey?.friendlyName || 'this passkey' } }))) return
@@ -89,7 +98,7 @@
await loadPasskeys()
toast.success($_('security.passkeyDeleted'))
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Failed to delete passkey')
handleReauthError(e, 'Failed to delete passkey', () => handleDeletePasskey(id))
}
}
@@ -282,6 +282,7 @@
{session}
{hasPassword}
onPasskeysChanged={(count) => passkeyCount = count}
onReauthRequired={handleReauthRequired}
/>
<TotpSection
+1
View File
@@ -522,6 +522,7 @@
"passkeyHintNotAvailable": "No passkey registered",
"passwordPlaceholder": "Password",
"usePasskey": "Use passkey",
"passkeyNotAllowed": "No passkey available for this site",
"orUseCredentials": "or",
"verificationResent": "Verification code sent"
},
+1
View File
@@ -509,6 +509,7 @@
"passkeyHintNotAvailable": "Ei pääsyavainta",
"passwordPlaceholder": "Salasana",
"usePasskey": "Käytä pääsyavainta",
"passkeyNotAllowed": "Pääsyavainta ei ole saatavilla tälle sivustolle",
"orUseCredentials": "tai",
"verificationResent": "Vahvistuskoodi lähetetty"
},
+1
View File
@@ -509,6 +509,7 @@
"passkeyHintNotAvailable": "パスキーなし",
"passwordPlaceholder": "パスワード",
"usePasskey": "パスキーを使用",
"passkeyNotAllowed": "このサイトで利用可能なパスキーがありません",
"orUseCredentials": "または",
"verificationResent": "確認コードを送信しました"
},
+1
View File
@@ -509,6 +509,7 @@
"passkeyHintNotAvailable": "패스키 없음",
"passwordPlaceholder": "비밀번호",
"usePasskey": "패스키 사용",
"passkeyNotAllowed": "이 사이트에 사용 가능한 패스키가 없습니다",
"orUseCredentials": "또는",
"verificationResent": "인증 코드 전송됨"
},
+1
View File
@@ -509,6 +509,7 @@
"passkeyHintNotAvailable": "Ingen nyckel registrerad",
"passwordPlaceholder": "Lösenord",
"usePasskey": "Använd nyckel",
"passkeyNotAllowed": "Ingen nyckel tillgänglig för denna webbplats",
"orUseCredentials": "eller",
"verificationResent": "Verifieringskod skickad"
},
+1
View File
@@ -509,6 +509,7 @@
"passkeyHintNotAvailable": "未注册通行密钥",
"passwordPlaceholder": "密码",
"usePasskey": "使用通行密钥",
"passkeyNotAllowed": "此站点没有可用的通行密钥",
"orUseCredentials": "或",
"verificationResent": "验证码已发送"
},
+72 -92
View File
@@ -38,13 +38,8 @@
let submitting = $state(false)
let error = $state<string | null>(null)
let verificationResent = $state(false)
let hasPasskeys = $state(false)
let hasTotp = $state(false)
let hasPassword = $state(true)
let isDelegated = $state(false)
let userDid = $state<string | null>(null)
let checkingSecurityStatus = $state(false)
let securityStatusChecked = $state(false)
let passkeySupported = $state(false)
let clientName = $state<string | null>(null)
@@ -160,31 +155,27 @@
let checkTimeout: ReturnType<typeof setTimeout> | null = null
let checkingDelegation = false
$effect(() => {
if (checkTimeout) {
clearTimeout(checkTimeout)
}
hasPasskeys = false
hasTotp = false
securityStatusChecked = false
isDelegated = false
if (username.length >= 3) {
checkTimeout = setTimeout(() => checkUserSecurityStatus(), 500)
checkTimeout = setTimeout(() => checkDelegationStatus(), 500)
}
})
async function checkUserSecurityStatus() {
if (!username || checkingSecurityStatus) return
checkingSecurityStatus = true
async function checkDelegationStatus() {
if (!username || checkingDelegation) return
checkingDelegation = true
try {
const response = await fetch(`/oauth/security-status?identifier=${encodeURIComponent(username)}`)
if (response.ok) {
const data = await response.json()
hasPasskeys = passkeySupported && data.hasPasskeys === true
hasTotp = data.hasTotp === true
hasPassword = data.hasPassword !== false
isDelegated = data.isDelegated === true
userDid = data.did || null
securityStatusChecked = true
if (isDelegated && data.did) {
const requestUri = getRequestUri()
@@ -198,19 +189,16 @@
}
}
} catch {
hasPasskeys = false
hasTotp = false
hasPassword = true
isDelegated = false
} finally {
checkingSecurityStatus = false
checkingDelegation = false
}
}
async function handlePasskeyLogin() {
const requestUri = getRequestUri()
if (!requestUri || !username) {
if (!requestUri) {
error = $_('common.error')
return
}
@@ -220,16 +208,18 @@
verificationResent = false
try {
const body: Record<string, string> = { request_uri: requestUri }
if (username.trim()) {
body.identifier = username
}
const startResponse = await fetch('/oauth/passkey/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
request_uri: requestUri,
identifier: username
})
body: JSON.stringify(body)
})
if (!startResponse.ok) {
@@ -306,9 +296,9 @@
} catch (e) {
console.error('Passkey login error:', e)
if (e instanceof DOMException && e.name === 'NotAllowedError') {
error = $_('common.error')
error = $_('oauth.login.passkeyNotAllowed')
} else {
error = `${$_('common.error')}: ${e instanceof Error ? e.message : String(e)}`
error = e instanceof Error ? e.message : String(e)
}
submitting = false
}
@@ -439,17 +429,15 @@
</div>
{/if}
{#if passkeySupported && username.length >= 3}
<div class="auth-methods" class:single-method={!hasPassword}>
{#if passkeySupported}
<div class="auth-methods">
<div class="passkey-method">
<h3>{$_('oauth.login.signInWithPasskey')}</h3>
<button
type="button"
style="width: 100%"
class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked}
onclick={handlePasskeyLogin}
disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked}
title={checkingSecurityStatus ? $_('oauth.login.passkeyHintChecking') : hasPasskeys ? $_('oauth.login.passkeyHintAvailable') : $_('oauth.login.passkeyHintNotAvailable')}
disabled={submitting}
>
<svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
@@ -459,80 +447,72 @@
<span class="passkey-text">
{#if submitting}
{$_('oauth.login.authenticating')}
{:else if checkingSecurityStatus || !securityStatusChecked}
{$_('oauth.login.checkingPasskey')}
{:else if hasPasskeys}
{$_('oauth.login.usePasskey')}
{:else}
{$_('oauth.login.passkeyNotSetUp')}
{$_('oauth.login.usePasskey')}
{/if}
</span>
</button>
</div>
{#if hasPassword}
<div class="method-divider">
<span>{$_('oauth.login.orUsePassword')}</span>
<div class="method-divider">
<span>{$_('oauth.login.orUsePassword')}</span>
</div>
<div class="password-method">
<h3>{$_('oauth.login.password')}</h3>
<div class="field">
<input
id="password"
type="password"
bind:value={password}
disabled={submitting}
required
autocomplete="current-password"
placeholder={$_('oauth.login.passwordPlaceholder')}
/>
</div>
<div class="password-method">
<h3>{$_('oauth.login.password')}</h3>
<div class="field">
<input
id="password"
type="password"
bind:value={password}
disabled={submitting}
required
autocomplete="current-password"
placeholder={$_('oauth.login.passwordPlaceholder')}
/>
</div>
<label class="remember-device">
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
<span>{$_('oauth.login.rememberDevice')}</span>
</label>
<label class="remember-device">
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
<span>{$_('oauth.login.rememberDevice')}</span>
</label>
<div class="actions">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
<div class="actions">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
{/if}
</div>
</div>
{:else}
{#if hasPassword || !securityStatusChecked}
<div>
<label for="password">{$_('oauth.login.password')}</label>
<input
id="password"
type="password"
bind:value={password}
disabled={submitting}
required
autocomplete="current-password"
/>
</div>
<div>
<label for="password">{$_('oauth.login.password')}</label>
<input
id="password"
type="password"
bind:value={password}
disabled={submitting}
required
autocomplete="current-password"
/>
</div>
<label class="remember-device">
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
<span>{$_('oauth.login.rememberDevice')}</span>
</label>
<label class="remember-device">
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
<span>{$_('oauth.login.rememberDevice')}</span>
</label>
<div class="actions">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
{/if}
<div class="actions">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
{/if}
</form>
-1
View File
@@ -56,7 +56,6 @@ test-misc:
./scripts/run-tests.sh --test actor --test commit_signing --test image_processing --test lifecycle_social --test notifications --test server --test signing_key --test verify_live_commit
test *args:
@just test-store
@just test-unit
./scripts/run-tests.sh {{args}}
+16 -11
View File
@@ -1,15 +1,17 @@
self: {
{
lib,
pkgs,
config,
...
}: let
}:
let
cfg = config.services.tranquil-pds;
inherit (lib) types mkOption;
settingsFormat = pkgs.formats.toml { };
in {
in
{
_class = "nixos";
options.services.tranquil-pds = {
@@ -17,8 +19,8 @@ in {
package = mkOption {
type = types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.tranquil-pds;
defaultText = lib.literalExpression "self.packages.\${pkgs.stdenv.hostPlatform.system}.tranquil-pds";
default = pkgs.callPackage ./default.nix { };
defaultText = lib.literalExpression "pkgs.tranquil-pds";
description = "The tranquil-pds package to use";
};
@@ -97,13 +99,16 @@ in {
};
frontend = {
enabled = lib.mkEnableOption "serving the frontend from the backend. Disable to serve the frontend manually"
// { default = true; };
enabled =
lib.mkEnableOption "serving the frontend from the backend. Disable to serve the frontend manually"
// {
default = true;
};
dir = mkOption {
type = types.nullOr types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.tranquil-frontend;
defaultText = lib.literalExpression "self.packages.\${pkgs.stdenv.hostPlatform.system}.tranquil-frontend";
default = pkgs.callPackage ./frontend.nix { };
defaultText = lib.literalExpression "pkgs.tranquil-frontend";
description = "Frontend package to be served by the backend";
};
};
@@ -137,7 +142,7 @@ in {
};
config = lib.mkIf cfg.enable (
lib.mkMerge [
lib.mkMerge [
(lib.mkIf cfg.database.createLocally {
services.postgresql = {
enable = true;
@@ -159,7 +164,7 @@ in {
};
})
{
{
users.users.${cfg.user} = {
isSystemUser = true;
inherit (cfg) group;
+10 -6
View File
@@ -207,11 +207,15 @@ if ! command -v rustc &>/dev/null; then
source "$HOME/.cargo/env"
fi
log_info "Installing deno..."
export PATH="$HOME/.deno/bin:$PATH"
if ! command -v deno &>/dev/null && [[ ! -f "$HOME/.deno/bin/deno" ]]; then
curl -fsSL https://deno.land/install.sh | sh
grep -q 'deno/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.bashrc
log_info "Installing Node.js..."
if ! command -v node &>/dev/null; then
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt install -y nodejs
fi
log_info "Installing pnpm..."
if ! command -v pnpm &>/dev/null; then
npm install -g pnpm
fi
log_info "Cloning Tranquil PDS..."
@@ -223,7 +227,7 @@ fi
cd /opt/tranquil-pds
log_info "Building frontend..."
"$HOME/.deno/bin/deno" task build --filter=frontend
cd frontend && pnpm install --frozen-lockfile && pnpm build && cd ..
log_success "Frontend built"
log_info "Building Tranquil PDS (this takes a while)..."
+1 -1
View File
@@ -23,7 +23,7 @@ cargo test --no-run 2>&1 | tail -1
echo "Running tests..."
echo ""
cargo nextest run "$@"
cargo nextest run -E 'not package(tranquil-store)' "$@"
echo ""
echo "All tests passed."
+11 -9
View File
@@ -1,5 +1,4 @@
{
lib,
mkShell,
callPackage,
rustPlatform,
@@ -17,13 +16,18 @@
cargo-nextest,
# frontend tooling
deno,
svelte-language-server,
typescript-language-server,
}: let
defaultPackage = callPackage ./default.nix { };
in mkShell {
inputsFrom = [ defaultPackage ];
}:
let
pds = callPackage ./default.nix { };
frontend = callPackage ./frontend.nix { };
in
mkShell {
inputsFrom = [
pds
frontend
];
env = {
RUST_SRC_PATH = rustPlatform.rustLibSrc;
@@ -39,10 +43,8 @@ in mkShell {
rust-analyzer
sqlx-cli
cargo-nextest
deno
svelte-language-server
typescript-language-server
];
}