From a7d39a48ca46908718bdbaab123c485e868b74ef Mon Sep 17 00:00:00 2001 From: fosskar <117449098+fosskar@users.noreply.github.com> Date: Wed, 22 Jul 2026 05:24:24 +0000 Subject: [PATCH 1/6] nix: harden custom data directory ownership --- Cargo.lock | 10 +-- Cargo.toml | 2 +- nix/checks.nix | 1 + nix/module-contract-test.nix | 148 +++++++++++++++++++++++++++++++++++ nix/nixos-module.nix | 46 +++++++---- nix/vm-module-test.nix | 26 ++++++ 6 files changed, 213 insertions(+), 20 deletions(-) create mode 100644 nix/module-contract-test.nix diff --git a/Cargo.lock b/Cargo.lock index 2a381fa..36990ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4314,7 +4314,7 @@ dependencies = [ [[package]] name = "ssync" -version = "0.19.1" +version = "0.19.2" dependencies = [ "anyhow", "clap", @@ -4329,14 +4329,14 @@ dependencies = [ [[package]] name = "ssync-adapters" -version = "0.19.1" +version = "0.19.2" dependencies = [ "anyhow", ] [[package]] name = "ssync-core" -version = "0.19.1" +version = "0.19.2" dependencies = [ "anyhow", "dirs", @@ -4351,7 +4351,7 @@ dependencies = [ [[package]] name = "ssync-crypto" -version = "0.19.1" +version = "0.19.2" dependencies = [ "age", "anyhow", @@ -4360,7 +4360,7 @@ dependencies = [ [[package]] name = "ssync-net" -version = "0.19.1" +version = "0.19.2" dependencies = [ "anyhow", "bytes", diff --git a/Cargo.toml b/Cargo.toml index e855192..9842fe1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.19.1" +version = "0.19.2" edition = "2024" license = "MIT" repository = "https://github.com/fosskar/ssync" diff --git a/nix/checks.nix b/nix/checks.nix index e94f498..20d1232 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -10,6 +10,7 @@ package = self.packages.${system}.default; devshell = self.devShells.${system}.default; formatting = treefmtEval.config.build.check self; + module-contract = import ./module-contract-test.nix { inherit pkgs self; }; # close-to-real end-to-end: two NixOS VMs syncing a session over a virtual LAN. vm-sync = import ./vm-test.nix { inherit pkgs self system; }; diff --git a/nix/module-contract-test.nix b/nix/module-contract-test.nix new file mode 100644 index 0000000..6245d11 --- /dev/null +++ b/nix/module-contract-test.nix @@ -0,0 +1,148 @@ +{ pkgs, self }: +let + inherit (pkgs) lib; + agents = [ + { + agent = "pi"; + sessionDir = "/sessions"; + } + ]; + nixosService = + extra: + (import (pkgs.path + "/nixos/lib/eval-config.nix") { + system = null; + modules = [ + { nixpkgs.hostPlatform = pkgs.stdenv.hostPlatform; } + self.nixosModules.default + { + services.ssync = { + enable = true; + user = "root"; + inherit agents; + } + // extra; + } + ]; + }).config.systemd.services.ssync.serviceConfig; + nixosDefault = nixosService { }; + nixosCustom = nixosService { dataDir = "/srv/ssync-data"; }; + nixosExternal = nixosService { + dataDir = "/srv/ssync-data"; + ageIdentityFile = "/run/secrets/age.key"; + nodeKeyFile = "/run/secrets/node.key"; + clusterFile = "/run/secrets/cluster.toml"; + }; + + hmStub = + { lib, ... }: + { + options = { + assertions = lib.mkOption { + type = lib.types.listOf lib.types.anything; + default = [ ]; + }; + home.packages = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = [ ]; + }; + home.homeDirectory = lib.mkOption { type = lib.types.str; }; + home.username = lib.mkOption { type = lib.types.str; }; + xdg.dataHome = lib.mkOption { type = lib.types.str; }; + xdg.configFile = lib.mkOption { + type = lib.types.attrsOf lib.types.anything; + default = { }; + }; + systemd.user.tmpfiles.rules = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + }; + systemd.user.services = lib.mkOption { + type = lib.types.attrsOf lib.types.anything; + default = { }; + }; + systemd.user.timers = lib.mkOption { + type = lib.types.attrsOf lib.types.anything; + default = { }; + }; + }; + config = { + home.homeDirectory = "/home/alice"; + home.username = "alice"; + xdg.dataHome = "/home/alice/.local/share"; + }; + }; + hmService = + (lib.evalModules { + specialArgs = { inherit pkgs; }; + modules = [ + hmStub + self.homeManagerModules.default + { + services.ssync = { + enable = true; + inherit agents; + }; + } + ]; + }).config.systemd.user.services.ssync.Service; + + words = value: if builtins.isList value then value else lib.splitString " " value; + hardeningContract = + service: + assert service.NoNewPrivileges; + assert service.ProtectSystem == "strict"; + assert service.ProtectHome == "read-only"; + assert service.PrivateTmp; + assert service.PrivateDevices; + assert service.ProtectClock; + assert service.ProtectHostname; + assert service.ProtectKernelTunables; + assert service.ProtectKernelModules; + assert service.ProtectKernelLogs; + assert service.ProtectControlGroups; + assert service.ProtectProc == "invisible"; + assert service.ProcSubset == "pid"; + assert service.RestrictNamespaces; + assert service.RestrictRealtime; + assert service.RestrictSUIDSGID; + assert + words service.RestrictAddressFamilies == [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + "AF_NETLINK" + ]; + assert service.LockPersonality; + assert service.MemoryDenyWriteExecute; + assert service.RemoveIPC; + assert service.CapabilityBoundingSet == ""; + assert service.AmbientCapabilities == ""; + assert + service.SystemCallFilter == [ + "@system-service" + "~@privileged" + "~@resources" + ]; + assert service.SystemCallErrorNumber == "EPERM"; + assert service.SystemCallArchitectures == "native"; + assert service.UMask == "0077"; + true; +in +assert hardeningContract nixosDefault; +assert hardeningContract hmService; +assert nixosDefault.StateDirectory == "ssync"; +assert nixosDefault.StateDirectoryMode == "0700"; +assert nixosDefault.ReadWritePaths == [ "/sessions" ]; +assert !(nixosCustom ? StateDirectory); +assert + nixosCustom.ReadWritePaths == [ + "/sessions" + "/srv/ssync-data" + ]; +assert nixosExternal.ReadWritePaths == nixosCustom.ReadWritePaths; +assert + hmService.ReadWritePaths == [ + "/sessions" + "/home/alice/.local/share/ssync" + ]; +pkgs.runCommand "ssync-module-contract" { } "touch $out" diff --git a/nix/nixos-module.nix b/nix/nixos-module.nix index b1c89c6..fc51349 100644 --- a/nix/nixos-module.nix +++ b/nix/nixos-module.nix @@ -9,6 +9,7 @@ }: let cfg = config.services.ssync; + customDataDir = cfg.dataDir != "/var/lib/ssync"; # scalar keys must precede the [[agents]] tables (TOML). configFile = pkgs.writeText "ssync-config.toml" ( '' @@ -176,19 +177,25 @@ in default = "${cfg.dataDir}/age.key"; defaultText = lib.literalExpression "\"\${dataDir}/age.key\""; description = '' - Age identity file. If it does not exist the daemon generates one on - first run. Shared mode (`recipients = []`): it must be the *same* key on - every machine, so point this at a secret you distribute yourself (e.g. - sops-nix). Per-machine mode: each machine keeps its own key and lists - the other machines' recipients in `recipients`. The clan service - handles per-machine keys for you via clan.vars. + Age identity file. If it does not exist under `dataDir`, the daemon + generates one on first run. A path outside `dataDir` must already + exist and remains read-only under the sandbox. Shared mode + (`recipients = []`): it must be the *same* key on every machine, so + point this at a secret you distribute yourself (e.g. sops-nix). + Per-machine mode: each machine keeps its own key and lists the other + machines' recipients in `recipients`. The clan service handles + per-machine keys for you via clan.vars. ''; }; dataDir = lib.mkOption { type = lib.types.str; default = "/var/lib/ssync"; - description = "ssync's own managed state (node key, blobs, docs, index)."; + description = '' + ssync's own managed state (node key, blobs, docs, index). The default + uses systemd's `StateDirectory`; a custom path is created 0700 for + `user` and added to the sandbox's writable allow-list. + ''; }; clusterFile = lib.mkOption { @@ -199,14 +206,18 @@ in namespace secret, every machine's age recipient, and node-ids. Manage it with `ssync cluster`, or let the clan service generate it via clan.vars. When set, peers join one deterministic namespace with - no ticket exchange; mutually exclusive with `recipients`. + no ticket exchange; mutually exclusive with `recipients`. A path + outside `dataDir` must be pre-provisioned and remains read-only. ''; }; nodeKeyFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; - description = "Override the iroh node key path (default: dataDir/node.key)."; + description = '' + Override the iroh node key path (default: `dataDir/node.key`). A path + outside `dataDir` must be pre-provisioned and remains read-only. + ''; }; pathMap = lib.mkOption { @@ -344,9 +355,10 @@ in environment.systemPackages = [ cfg.package ]; environment.etc."ssync/config.toml".source = configFile; - # ensure the watched session dirs exist so the sandbox's ReadWritePaths bind - # succeeds on first boot (owner cfg.user, 0700). - systemd.tmpfiles.rules = map (a: "d ${a.sessionDir} 0700 ${cfg.user} - - -") cfg.agents; + # ReadWritePaths requires watched and custom state dirs to exist at start. + systemd.tmpfiles.rules = + map (a: "d \"${a.sessionDir}\" 0700 ${cfg.user} - - -") cfg.agents + ++ lib.optional customDataDir "d \"${cfg.dataDir}\" 0700 ${cfg.user} - - -"; systemd.services.ssync = { description = "ssync coding-agent session sync"; @@ -356,14 +368,20 @@ in serviceConfig = { ExecStart = "${cfg.package}/bin/ssync --config ${configFile} daemon"; User = cfg.user; - StateDirectory = "ssync"; Restart = "on-failure"; RestartSec = 5; # cap glibc malloc arenas: transient session read/encrypt buffers across # tokio workers otherwise pin the peak-import high-water mark as RSS. Environment = [ "MALLOC_ARENA_MAX=2" ]; } - // hardening; + // lib.optionalAttrs (!customDataDir) { + StateDirectory = "ssync"; + StateDirectoryMode = "0700"; + } + // hardening + // lib.optionalAttrs customDataDir { + ReadWritePaths = hardening.ReadWritePaths ++ [ cfg.dataDir ]; + }; }; # scheduled cleanup: prune old sessions via the plain cleanup CLI; the diff --git a/nix/vm-module-test.nix b/nix/vm-module-test.nix index 2f138fb..2eab7c1 100644 --- a/nix/vm-module-test.nix +++ b/nix/vm-module-test.nix @@ -56,6 +56,23 @@ pkgs.testers.runNixOSTest { ''; }; + nodes.custom = + { pkgs, ... }: + { + imports = [ self.nixosModules.default ]; + services.ssync = { + enable = true; + user = "root"; + ageIdentityFile = "/run/ssync-test-key/age.key"; + dataDir = "/srv/ssync-data"; + }; + system.activationScripts.ssyncExternalAge = '' + install -d -m 0700 /run/ssync-test-key + ${pkgs.age}/bin/age-keygen -pq -o /run/ssync-test-key/age.key + chmod 0600 /run/ssync-test-key/age.key + ''; + }; + testScript = '' machine.wait_for_unit("ssync.service") machine.wait_for_file("/var/lib/ssync/age.key") @@ -82,5 +99,14 @@ pkgs.testers.runNixOSTest { cluster.succeed(f"! grep -q 'recipients = ' {ccfg}") cluster.wait_until_succeeds("journalctl -u ssync | grep -q 'cluster namespace'", timeout=60) cluster.succeed("! test -e /var/lib/ssync/ticket") + + # A custom dataDir carries its storage ownership and sandbox grant with it. + custom.wait_for_unit("ssync.service") + custom.wait_for_file("/run/ssync-test-key/age.key") + custom.wait_for_file("/srv/ssync-data/ticket") + custom.succeed("test $(stat -c%a /srv/ssync-data) = 700") + custom.succeed("systemctl show ssync -p ReadWritePaths --value | grep -q /srv/ssync-data") + custom.succeed("systemctl show ssync -p ReadWritePaths --value | grep -qv /run/ssync-test-key") + custom.succeed("test -z \"$(systemctl show ssync -p StateDirectory --value)\"") ''; } From e986d9272c2ab7c86ae66cadfd3a1c6620379e19 Mon Sep 17 00:00:00 2001 From: fosskar <117449098+fosskar@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:12:15 +0000 Subject: [PATCH 2/6] crypto: bound age subprocess lifetime --- Cargo.lock | 11 +- Cargo.toml | 2 +- crates/ssync-core/src/lib.rs | 29 +- crates/ssync-core/tests/recipient_rotation.rs | 1 + crates/ssync-crypto/Cargo.toml | 1 + crates/ssync-crypto/src/lib.rs | 397 +++++++++++++++--- 6 files changed, 376 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 36990ef..13287ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4314,7 +4314,7 @@ dependencies = [ [[package]] name = "ssync" -version = "0.19.2" +version = "0.19.3" dependencies = [ "anyhow", "clap", @@ -4329,14 +4329,14 @@ dependencies = [ [[package]] name = "ssync-adapters" -version = "0.19.2" +version = "0.19.3" dependencies = [ "anyhow", ] [[package]] name = "ssync-core" -version = "0.19.2" +version = "0.19.3" dependencies = [ "anyhow", "dirs", @@ -4351,16 +4351,17 @@ dependencies = [ [[package]] name = "ssync-crypto" -version = "0.19.2" +version = "0.19.3" dependencies = [ "age", "anyhow", "secrecy", + "tokio", ] [[package]] name = "ssync-net" -version = "0.19.2" +version = "0.19.3" dependencies = [ "anyhow", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 9842fe1..f7eaed0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.19.2" +version = "0.19.3" edition = "2024" license = "MIT" repository = "https://github.com/fosskar/ssync" diff --git a/crates/ssync-core/src/lib.rs b/crates/ssync-core/src/lib.rs index 54594d3..09a60ed 100644 --- a/crates/ssync-core/src/lib.rs +++ b/crates/ssync-core/src/lib.rs @@ -161,7 +161,7 @@ impl Engine { { return Ok(ImportOutcome::Unchanged(w)); } - let ciphertext = self.identity.encrypt(&plaintext)?; + let ciphertext = self.identity.encrypt(&plaintext).await?; let hash = self.node.publish(key.to_string(), ciphertext).await?; Ok(ImportOutcome::Published(hash)) } @@ -241,7 +241,7 @@ impl Engine { async fn get_plain(&self, hash: Hash) -> Option> { let ciphertext = self.node.get_blob(hash).await.ok()?; - self.identity.decrypt(&ciphertext).ok() + self.identity.decrypt(&ciphertext).await.ok() } /// Publish the lossless union for a diverged key; the cached verdict gates @@ -267,7 +267,7 @@ impl Engine { let Verdict::Diverged(merged) = self.verdict_of(key, winner, &rec.versions).await else { return Ok(None); }; - let ciphertext = self.identity.encrypt(&merged)?; + let ciphertext = self.identity.encrypt(&merged).await?; self.node.publish(key.to_string(), ciphertext).await?; Ok(Some(rel)) } @@ -457,7 +457,7 @@ impl Engine { return false; } }; - let plaintext = match self.identity.decrypt(&ciphertext) { + let plaintext = match self.identity.decrypt(&ciphertext).await { Ok(p) => p, Err(e) => { eprintln!("ssync: decrypt {key}: {e:#}"); @@ -736,7 +736,7 @@ mod tests { AgeIdentity::generate().unwrap(), node, ); - let v1 = engine.identity.encrypt(b"h\na\n").unwrap(); + let v1 = engine.identity.encrypt(b"h\na\n").await.unwrap(); let h1 = engine.node.add_blob(v1).await.unwrap(); let missing = Hash::new(b"never-added"); @@ -763,9 +763,16 @@ mod tests { AgeIdentity::generate().unwrap(), node, ); - let enc = |b: &[u8]| engine.identity.encrypt(b).unwrap(); - let h1 = engine.node.add_blob(enc(b"h\na\n")).await.unwrap(); - let h2 = engine.node.add_blob(enc(b"h\nb\n")).await.unwrap(); + let h1 = engine + .node + .add_blob(engine.identity.encrypt(b"h\na\n").await.unwrap()) + .await + .unwrap(); + let h2 = engine + .node + .add_blob(engine.identity.encrypt(b"h\nb\n").await.unwrap()) + .await + .unwrap(); let Verdict::Diverged(union) = engine.verdict_of("k", h2, &[h1, h2]).await else { panic!("fork must read as diverged"); @@ -773,7 +780,11 @@ mod tests { assert_eq!(engine.divergence.cached("k", &[h1, h2]), Some(true)); // once the union is the winner, the same key settles - let hu = engine.node.add_blob(enc(&union)).await.unwrap(); + let hu = engine + .node + .add_blob(engine.identity.encrypt(&union).await.unwrap()) + .await + .unwrap(); assert_eq!( engine.verdict_of("k", hu, &[h1, h2, hu]).await, Verdict::Settled diff --git a/crates/ssync-core/tests/recipient_rotation.rs b/crates/ssync-core/tests/recipient_rotation.rs index d8d605a..1ef131b 100644 --- a/crates/ssync-core/tests/recipient_rotation.rs +++ b/crates/ssync-core/tests/recipient_rotation.rs @@ -93,6 +93,7 @@ async fn recipient_change_republishes_unchanged_sessions() { plain = AgeIdentity::from_secret_string(&id_b.to_secret_string()) .unwrap() .decrypt(&ciphertext) + .await .ok(); break; } diff --git a/crates/ssync-crypto/Cargo.toml b/crates/ssync-crypto/Cargo.toml index ce4b3bc..08157af 100644 --- a/crates/ssync-crypto/Cargo.toml +++ b/crates/ssync-crypto/Cargo.toml @@ -11,6 +11,7 @@ anyhow.workspace = true # backend shells out to the `age` CLI so it gets native post-quantum hybrid keys # (`age-keygen -pq`, ML-KEM-768 + X25519), which the Rust crate does not support. age = { workspace = true, optional = true } +tokio.workspace = true secrecy = { workspace = true, optional = true } [features] diff --git a/crates/ssync-crypto/src/lib.rs b/crates/ssync-crypto/src/lib.rs index f100c76..64bc473 100644 --- a/crates/ssync-crypto/src/lib.rs +++ b/crates/ssync-crypto/src/lib.rs @@ -1,14 +1,19 @@ //! age encryption at rest (DECISIONS §7). Shells out to `age`/`age-keygen` -//! (>= 1.3, on `PATH`) for native post-quantum hybrid keys (`age-keygen -pq`, -//! ML-KEM-768 + X25519). The X25519-only Rust `age` crate backend is kept, -//! disabled, in [`rust_age`] (feature `rust-age`) for when it gains ML-KEM. +//! (>= 1.3, resolved from `PATH` when an identity is constructed) for native +//! post-quantum hybrid keys (`age-keygen -pq`, ML-KEM-768 + X25519). The +//! X25519-only Rust `age` crate backend stays disabled behind `rust-age`. +use std::ffi::OsStr; use std::io::Write; -use std::os::unix::fs::OpenOptionsExt; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::PathBuf; -use std::process::{Command, Stdio}; +use std::process::{Command as StdCommand, Stdio}; +use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::process::{Child, Command}; +use tokio::time::Instant; #[cfg(feature = "rust-age")] pub mod rust_age; @@ -21,13 +26,14 @@ pub struct AgeIdentity { secret: String, recipient: String, extra_recipients: Vec, + commands: AgeCommands, } impl AgeIdentity { /// Generate a fresh post-quantum hybrid identity (`age-keygen -pq`). pub fn generate() -> Result { - let out = - run(Command::new("age-keygen").arg("-pq"), &[]).context("running age-keygen -pq")?; + let commands = AgeCommands::resolve()?; + let out = commands.keygen(["-pq"]).context("running age-keygen -pq")?; let text = String::from_utf8(out).context("age-keygen output not utf-8")?; let mut secret = None; let mut recipient = None; @@ -42,29 +48,35 @@ impl AgeIdentity { let secret = secret.ok_or_else(|| anyhow!("age-keygen produced no secret key"))?; let recipient = match recipient { Some(r) => r, - None => recipient_of(&secret)?, + None => recipient_of(&commands, &secret)?, }; Ok(Self { secret, recipient, extra_recipients: Vec::new(), + commands, }) } /// Build from an age identity: either a bare `AGE-SECRET-KEY[-PQ]-1…` line or /// a full `age-keygen` file (comment lines are ignored). pub fn from_secret_string(s: &str) -> Result { + Self::from_secret_string_with_commands(s, AgeCommands::resolve()?) + } + + fn from_secret_string_with_commands(s: &str, commands: AgeCommands) -> Result { let secret = s .lines() .map(str::trim) .find(|l| l.starts_with("AGE-SECRET-KEY-")) .ok_or_else(|| anyhow!("no age secret key found"))? .to_string(); - let recipient = recipient_of(&secret)?; + let recipient = recipient_of(&commands, &secret)?; Ok(Self { secret, recipient, extra_recipients: Vec::new(), + commands, }) } @@ -100,30 +112,104 @@ impl AgeIdentity { /// Encrypt `plaintext` to this identity's recipient plus any added peer /// recipients (binary age output). - pub fn encrypt(&self, plaintext: &[u8]) -> Result> { - let mut cmd = Command::new("age"); - cmd.args(["-e", "-r", &self.recipient]); - for r in &self.extra_recipients { - cmd.args(["-r", r]); + pub async fn encrypt(&self, plaintext: &[u8]) -> Result> { + let mut command = self.commands.age(); + command.args(["-e", "-r", &self.recipient]); + for recipient in &self.extra_recipients { + command.args(["-r", recipient]); } - run(&mut cmd, plaintext).context("age encrypt") + run(&mut command, plaintext, self.commands.inactivity_timeout) + .await + .context("age encrypt") } /// Decrypt age `ciphertext` with this identity. - pub fn decrypt(&self, ciphertext: &[u8]) -> Result> { + pub async fn decrypt(&self, ciphertext: &[u8]) -> Result> { let key = SecretFile::new(&self.secret)?; - run( - Command::new("age").arg("-d").arg("-i").arg(&key.path), - ciphertext, - ) - .context("age decrypt (wrong identity?)") + let mut command = self.commands.age(); + command.arg("-d").arg("-i").arg(&key.path); + run(&mut command, ciphertext, self.commands.inactivity_timeout) + .await + .context("age decrypt (wrong identity?)") } } -fn recipient_of(secret: &str) -> Result { +const INACTIVITY_TIMEOUT: Duration = Duration::from_secs(30); +const IO_CHUNK: usize = 64 * 1024; + +struct AgeCommands { + age: PathBuf, + age_keygen: PathBuf, + inactivity_timeout: Duration, +} + +impl AgeCommands { + fn resolve() -> Result { + Ok(Self::new( + resolve_executable("age")?, + resolve_executable("age-keygen")?, + INACTIVITY_TIMEOUT, + )) + } + + fn new(age: PathBuf, age_keygen: PathBuf, inactivity_timeout: Duration) -> Self { + Self { + age, + age_keygen, + inactivity_timeout, + } + } + + fn age(&self) -> Command { + let mut command = Command::new(&self.age); + command.env_clear(); + command + } + + fn keygen(&self, args: I) -> Result> + where + I: IntoIterator, + S: AsRef, + { + let output = StdCommand::new(&self.age_keygen) + .env_clear() + .args(args) + .stdin(Stdio::null()) + .output() + .with_context(|| format!("spawning {}", self.age_keygen.display()))?; + if !output.status.success() { + bail!("{}", String::from_utf8_lossy(&output.stderr).trim()); + } + Ok(output.stdout) + } +} + +fn resolve_executable(name: &str) -> Result { + let path = std::env::var_os("PATH").ok_or_else(|| anyhow!("PATH is not set"))?; + for dir in std::env::split_paths(&path) { + let candidate = dir.join(name); + let candidate = if candidate.is_absolute() { + candidate + } else { + std::env::current_dir() + .context("resolving executable path")? + .join(candidate) + }; + let Ok(metadata) = candidate.metadata() else { + continue; + }; + if metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 { + return Ok(candidate); + } + } + bail!("{name} not found on PATH") +} + +fn recipient_of(commands: &AgeCommands, secret: &str) -> Result { let key = SecretFile::new(secret)?; - let out = - run(Command::new("age-keygen").arg("-y").arg(&key.path), &[]).context("age-keygen -y")?; + let out = commands + .keygen(["-y".as_ref(), key.path.as_os_str()]) + .context("age-keygen -y")?; let recipient = String::from_utf8(out) .context("age-keygen -y output not utf-8")? .trim() @@ -134,23 +220,112 @@ fn recipient_of(secret: &str) -> Result { Ok(recipient) } -/// Run `cmd` with `input` on stdin, returning stdout; errors carry stderr. -fn run(cmd: &mut Command, input: &[u8]) -> Result> { - let mut child = cmd +async fn terminate(child: &mut Child) { + let _ = child.start_kill(); + let _ = child.wait().await; +} + +/// Run `command` with `input` on stdin. The timeout resets on every I/O event. +async fn run(command: &mut Command, input: &[u8], inactivity_timeout: Duration) -> Result> { + let program = command.as_std().get_program().to_owned(); + let mut child = command + .kill_on_drop(true) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() - .with_context(|| format!("spawning {:?}", cmd.get_program()))?; - let mut stdin = child.stdin.take().expect("piped stdin"); - let input = input.to_vec(); - let writer = std::thread::spawn(move || stdin.write_all(&input)); - let output = child.wait_with_output()?; - let _ = writer.join(); - if !output.status.success() { - bail!("{}", String::from_utf8_lossy(&output.stderr).trim()); - } - Ok(output.stdout) + .with_context(|| format!("spawning {program:?}"))?; + let mut stdin = child.stdin.take(); + let mut stdout = child.stdout.take().expect("piped stdout"); + let mut stderr = child.stderr.take().expect("piped stderr"); + let mut input_offset = 0; + let mut stdout_bytes = Vec::new(); + let mut stderr_bytes = Vec::new(); + let mut stdout_open = true; + let mut stderr_open = true; + let mut status = None; + let mut stdout_chunk = vec![0; IO_CHUNK]; + let mut stderr_chunk = vec![0; IO_CHUNK]; + if input.is_empty() { + stdin = None; + } + let idle = tokio::time::sleep(inactivity_timeout); + tokio::pin!(idle); + + while status.is_none() || stdout_open || stderr_open { + tokio::select! { + result = async { + stdin + .as_mut() + .expect("guarded stdin") + .write(&input[input_offset..]) + .await + }, if stdin.is_some() => { + match result { + Ok(0) => { + terminate(&mut child).await; + bail!("age child closed stdin"); + } + Ok(written) => { + input_offset += written; + if input_offset == input.len() + && let Some(mut pipe) = stdin.take() + && let Err(error) = pipe.shutdown().await + { + terminate(&mut child).await; + return Err(error).context("closing age stdin"); + } + } + Err(error) => { + terminate(&mut child).await; + return Err(error).context("writing age stdin"); + } + } + idle.as_mut().reset(Instant::now() + inactivity_timeout); + } + result = stdout.read(&mut stdout_chunk), if stdout_open => { + match result { + Ok(0) => stdout_open = false, + Ok(read) => stdout_bytes.extend_from_slice(&stdout_chunk[..read]), + Err(error) => { + terminate(&mut child).await; + return Err(error).context("reading age stdout"); + } + } + idle.as_mut().reset(Instant::now() + inactivity_timeout); + } + result = stderr.read(&mut stderr_chunk), if stderr_open => { + match result { + Ok(0) => stderr_open = false, + Ok(read) => stderr_bytes.extend_from_slice(&stderr_chunk[..read]), + Err(error) => { + terminate(&mut child).await; + return Err(error).context("reading age stderr"); + } + } + idle.as_mut().reset(Instant::now() + inactivity_timeout); + } + result = child.wait(), if status.is_none() => { + match result { + Ok(exit) => status = Some(exit), + Err(error) => { + terminate(&mut child).await; + return Err(error).context("waiting for age child"); + } + } + idle.as_mut().reset(Instant::now() + inactivity_timeout); + } + () = &mut idle => { + terminate(&mut child).await; + bail!("age child inactive for {}s", inactivity_timeout.as_secs_f64()); + } + } + } + + if !status.expect("loop waits for status").success() { + bail!("{}", String::from_utf8_lossy(&stderr_bytes).trim()); + } + Ok(stdout_bytes) } /// `0600` temp file holding a secret key, removed on drop (age wants `-i FILE`). @@ -208,18 +383,137 @@ fn nonce() -> u128 { #[cfg(test)] mod tests { use super::*; + use std::os::unix::fs::PermissionsExt; + use std::path::Path; + use std::time::{Duration, Instant}; + + fn scratch(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ssync-crypto-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn script(dir: &Path, name: &str, body: &str) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + path + } + + fn identity_with_commands(age: PathBuf, age_keygen: PathBuf, timeout: Duration) -> AgeIdentity { + AgeIdentity::from_secret_string_with_commands( + "AGE-SECRET-KEY-1-TEST", + AgeCommands::new(age, age_keygen, timeout), + ) + .unwrap() + } + + #[test] + fn injected_keygen_path_is_used() { + let dir = scratch("injected-keygen"); + let age = script(&dir, "age", "exit 1"); + let age_keygen = script(&dir, "age-keygen", "printf 'age1injected\\n'"); + let id = identity_with_commands(age, age_keygen, Duration::from_secs(1)); + + assert_eq!(id.recipient_string(), "age1injected"); + std::fs::remove_dir_all(dir).unwrap(); + } #[test] - fn round_trip_is_byte_identical() { + fn resolved_commands_are_absolute() { + let commands = AgeCommands::resolve().unwrap(); + assert!(commands.age.is_absolute()); + assert!(commands.age_keygen.is_absolute()); + } + + #[tokio::test] + async fn age_child_receives_empty_environment() { + let dir = scratch("empty-environment"); + let cat = resolve_executable("cat").unwrap(); + let age = script( + &dir, + "age", + &format!( + "if [ -s /proc/self/environ ]; then exit 9; fi\n'{}' >/dev/null\nprintf clean", + cat.display() + ), + ); + let age_keygen = script(&dir, "age-keygen", "printf 'age1injected\\n'"); + let id = identity_with_commands(age, age_keygen, Duration::from_secs(1)); + + assert_eq!(id.encrypt(b"plaintext").await.unwrap(), b"clean"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[tokio::test] + async fn inactivity_timeout_kills_and_reaps_child() { + let dir = scratch("inactivity"); + let pid_file = dir.join("pid"); + let sleep = resolve_executable("sleep").unwrap(); + let age = script( + &dir, + "age", + &format!( + "printf '%s' $$ > '{}'\nprintf ready\nexec '{}' 60", + pid_file.display(), + sleep.display() + ), + ); + let age_keygen = script(&dir, "age-keygen", "printf 'age1injected\\n'"); + let id = identity_with_commands(age, age_keygen, Duration::from_millis(500)); + + let started = Instant::now(); + let error = id.encrypt(b"plaintext").await.unwrap_err(); + assert!( + format!("{error:#}").contains("inactive"), + "unexpected error: {error:#}" + ); + assert!(started.elapsed() < Duration::from_secs(2)); + let pid = std::fs::read_to_string(&pid_file).unwrap(); + let pid = pid.trim(); + assert!(!pid.is_empty(), "child did not record its pid"); + assert!( + !Path::new("/proc").join(pid).exists(), + "child was not reaped" + ); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[tokio::test] + async fn decrypt_identity_file_lives_for_one_operation() { + let dir = scratch("identity-lifetime"); + let report = dir.join("identity-path"); + let cat = resolve_executable("cat").unwrap(); + let age = script( + &dir, + "age", + &format!( + "while [ \"$#\" -gt 0 ]; do\n if [ \"$1\" = -i ]; then key=$2; shift 2; else shift; fi\ndone\n[ -f \"$key\" ] || exit 9\nprintf '%s' \"$key\" > '{}'\n'{}' >/dev/null\nprintf plain", + report.display(), + cat.display() + ), + ); + let age_keygen = script(&dir, "age-keygen", "printf 'age1injected\\n'"); + let id = identity_with_commands(age, age_keygen, Duration::from_secs(1)); + + assert_eq!(id.decrypt(b"ciphertext").await.unwrap(), b"plain"); + let identity_path = std::fs::read_to_string(report).unwrap(); + assert!(!Path::new(&identity_path).exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[tokio::test] + async fn round_trip_is_byte_identical() { let id = AgeIdentity::generate().unwrap(); assert!( id.recipient_string().starts_with("age1pq1"), "expected PQ recipient" ); let plaintext = b"{\"type\":\"session\",\"version\":3}\n{\"secret\":\"sk-abc\"}\n"; - let ct = id.encrypt(plaintext).unwrap(); + let ct = id.encrypt(plaintext).await.unwrap(); assert_ne!(&ct[..], &plaintext[..]); - let pt = id.decrypt(&ct).unwrap(); + let pt = id.decrypt(&ct).await.unwrap(); assert_eq!(&pt[..], &plaintext[..]); } @@ -242,25 +536,28 @@ mod tests { assert_eq!(id.recipient_string(), id2.recipient_string()); } - #[test] - fn wrong_identity_cannot_decrypt() { + #[tokio::test] + async fn wrong_identity_cannot_decrypt() { let a = AgeIdentity::generate().unwrap(); let b = AgeIdentity::generate().unwrap(); - let ct = a.encrypt(b"hello").unwrap(); - assert!(b.decrypt(&ct).is_err()); + let ct = a.encrypt(b"hello").await.unwrap(); + assert!(b.decrypt(&ct).await.is_err()); } - #[test] - fn extra_recipients_can_decrypt_and_self_stays_included() { + #[tokio::test] + async fn extra_recipients_can_decrypt_and_self_stays_included() { let a = AgeIdentity::generate().unwrap(); let b = AgeIdentity::generate().unwrap(); let c = AgeIdentity::generate().unwrap(); let mut sender = AgeIdentity::from_secret_string(&a.to_secret_string()).unwrap(); // duplicate of self plus b: dedup must not break encryption sender.add_recipients([a.recipient_string(), b.recipient_string()]); - let ct = sender.encrypt(b"shared session").unwrap(); - assert_eq!(a.decrypt(&ct).unwrap(), b"shared session"); - assert_eq!(b.decrypt(&ct).unwrap(), b"shared session"); - assert!(c.decrypt(&ct).is_err(), "non-recipient must not decrypt"); + let ct = sender.encrypt(b"shared session").await.unwrap(); + assert_eq!(a.decrypt(&ct).await.unwrap(), b"shared session"); + assert_eq!(b.decrypt(&ct).await.unwrap(), b"shared session"); + assert!( + c.decrypt(&ct).await.is_err(), + "non-recipient must not decrypt" + ); } } From c501487ee0e91408ef5271d5730a076e7dc7d7a3 Mon Sep 17 00:00:00 2001 From: fosskar <117449098+fosskar@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:52:45 +0000 Subject: [PATCH 3/6] core: deepen session filesystem module --- AGENTS.md | 9 +- Cargo.lock | 10 +- Cargo.toml | 2 +- crates/ssync-core/src/cleanup.rs | 3 +- crates/ssync-core/src/config.rs | 4 +- crates/ssync-core/src/lib.rs | 180 ++--- crates/ssync-core/src/search.rs | 4 +- crates/ssync-core/src/session_filesystem.rs | 744 ++++++++++++++++++ .../src/{ => session_filesystem}/pathmap.rs | 201 +---- crates/ssync-core/src/wiremap.rs | 365 --------- 10 files changed, 836 insertions(+), 686 deletions(-) create mode 100644 crates/ssync-core/src/session_filesystem.rs rename crates/ssync-core/src/{ => session_filesystem}/pathmap.rs (71%) delete mode 100644 crates/ssync-core/src/wiremap.rs diff --git a/AGENTS.md b/AGENTS.md index 8f5d1c4..0e6141b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,11 +25,10 @@ Impure shell around pure decision cores: - Each tick: `local_snapshot` + `index_snapshot` → pure `reconcile()` → `Vec` {Import, WriteFile, DeleteLocal, Tombstone, Merge} → execute → settle into `SyncState` (atomic temp+rename to `data_dir/state.toml`). -- The `Wiremap` (ssync-core `wiremap.rs`) owns the adapters, the per-agent excludes, and - the path-map resolver: every wire-key ↔ local-path translation and every freeze verdict - (excluded key #14, dropped-agent guard, failed mapping #49) lives behind it — Engine - never touches adapters directly, and the freeze invariants are unit-tested there - without iroh. +- `SessionFilesystem` (ssync-core `session_filesystem.rs`) owns adapters, bounded discovery, + wire-key ↔ local-path translation, path-map resolution, freeze verdicts, and contained + read/write/delete. Engine never touches adapters or filesystem policy directly; tests cover + the interface without iroh. - Import path: file → age-encrypt (subprocess) → `Node::publish` (blob + index entry keyed `{agent}/{relative_path}`; the ONLY write path — temp-tag prevents the GC race). - Export path: index entry → `Node::blob` (local miss → bounded fetch from peers behind diff --git a/Cargo.lock b/Cargo.lock index 13287ca..1e20ce9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4314,7 +4314,7 @@ dependencies = [ [[package]] name = "ssync" -version = "0.19.3" +version = "0.19.4" dependencies = [ "anyhow", "clap", @@ -4329,14 +4329,14 @@ dependencies = [ [[package]] name = "ssync-adapters" -version = "0.19.3" +version = "0.19.4" dependencies = [ "anyhow", ] [[package]] name = "ssync-core" -version = "0.19.3" +version = "0.19.4" dependencies = [ "anyhow", "dirs", @@ -4351,7 +4351,7 @@ dependencies = [ [[package]] name = "ssync-crypto" -version = "0.19.3" +version = "0.19.4" dependencies = [ "age", "anyhow", @@ -4361,7 +4361,7 @@ dependencies = [ [[package]] name = "ssync-net" -version = "0.19.3" +version = "0.19.4" dependencies = [ "anyhow", "bytes", diff --git a/Cargo.toml b/Cargo.toml index f7eaed0..a113d26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.19.3" +version = "0.19.4" edition = "2024" license = "MIT" repository = "https://github.com/fosskar/ssync" diff --git a/crates/ssync-core/src/cleanup.rs b/crates/ssync-core/src/cleanup.rs index 7c3a081..0d29b52 100644 --- a/crates/ssync-core/src/cleanup.rs +++ b/crates/ssync-core/src/cleanup.rs @@ -69,7 +69,8 @@ pub fn plan(adapters: &[Box], filter: &Filter) -> Result Result { - crate::pathmap::PathMap::new( + pub fn build_path_map(&self) -> Result { + crate::PathMap::new( self.path_map .iter() .map(|e| { diff --git a/crates/ssync-core/src/lib.rs b/crates/ssync-core/src/lib.rs index 09a60ed..65f06fc 100644 --- a/crates/ssync-core/src/lib.rs +++ b/crates/ssync-core/src/lib.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; -use anyhow::{Context, Result, anyhow, ensure}; +use anyhow::{Context, Result, ensure}; use notify::{RecursiveMode, Watcher}; use ssync_adapters::Adapter; use ssync_crypto::AgeIdentity; @@ -17,17 +17,16 @@ pub mod cluster; mod config; mod divergence; mod exclude; -mod pathmap; mod reconcile; pub mod search; +mod session_filesystem; mod status; -mod wiremap; pub use config::{AgentConfig, Config, Discovery, insert_cluster_path}; use divergence::{Divergence, Verdict}; -pub use pathmap::PathMap; -use reconcile::{Action, IndexEntry, IndexHead, LocalFile, SyncState, reconcile}; +use reconcile::{Action, IndexEntry, IndexHead, SyncState, reconcile}; +pub use session_filesystem::PathMap; +use session_filesystem::SessionFilesystem; pub use status::{PeerStatus, StatusReport}; -use wiremap::{KeyLookup, Wiremap}; /// Consecutive state-persist ENOENT failures after which the daemon exits so /// its supervisor restarts it with a fresh mount namespace. @@ -36,7 +35,7 @@ const PERSIST_WEDGE_THRESHOLD: u32 = 5; /// The sync engine for one node: one or more agent adapters (pi, omp, ...) /// sharing a single index namespace, partitioned by the `{agent}/` key prefix. pub struct Engine { - wiremap: Wiremap, + filesystem: SessionFilesystem, identity: AgeIdentity, node: Node, /// What we last materialised per key; feeds [`reconcile`]. @@ -45,8 +44,6 @@ pub struct Engine { state_path: Option, /// Merges already announced (log once, not per tick). merged_logged: std::collections::HashSet, - /// Identify failures already announced (log once, not per tick). - identify_logged: std::collections::HashSet, /// Conflicts already announced; replaced with the current set on every /// announcing pass so a resolved-then-returned conflict logs again. conflicts_logged: std::collections::HashSet, @@ -81,13 +78,12 @@ impl Engine { ) -> Self { let recipients_fp = Hash::new(identity.recipients().join("\n")).to_string(); Self { - wiremap: Wiremap::new(adapters), + filesystem: SessionFilesystem::new(adapters), identity, node, state: SyncState::default(), state_path: None, merged_logged: Default::default(), - identify_logged: Default::default(), conflicts_logged: Default::default(), persist_enoent: 0, divergence: Divergence::default(), @@ -116,12 +112,12 @@ impl Engine { /// Per-agent `exclude` patterns from config (`[[agents]]` tables). pub fn set_excludes(&mut self, excludes: std::collections::HashMap>) { - self.wiremap.set_excludes(excludes); + self.filesystem.set_excludes(excludes); } /// The `[[path_map]]` + `canonical_home` from config (issue #13). pub fn set_path_map(&mut self, map: PathMap, canonical_home: Option) { - self.wiremap.set_path_map(map, canonical_home); + self.filesystem.set_path_map(map, canonical_home); } /// Live index keys (wire form) — status/debug surface; the two-node @@ -145,16 +141,13 @@ impl Engine { /// `force` (recipient-set rotation) bypasses the dedup: the plaintext is /// unchanged but the encryption no longer matches the configured set. async fn import_action( - &self, + &mut self, key: &str, path: &Path, winner: Option, force: bool, ) -> Result { - let plaintext = tokio::fs::read(path) - .await - .with_context(|| format!("reading session file {}", path.display()))?; - let plaintext = self.wiremap.canonical_plaintext(key, plaintext)?; + let plaintext = self.filesystem.read(key, path).await?; if !force && let Some(w) = winner && self.get_plain(w).await.as_deref() == Some(&plaintext) @@ -196,10 +189,10 @@ impl Engine { continue; // deleted — never resurrect from stale live entries }; let key = String::from_utf8(rec.key).context("index key not utf-8")?; - if self.wiremap.excluded(&key) { + if self.filesystem.excluded(&key) { continue; // frozen keys neither count nor report conflicts } - match self.wiremap.session_identity_of_key(&key) { + match self.filesystem.session_identity_of_key(&key) { Some(id) => sessions.insert((id.agent, id.project_id, id.session_id)), // unconfigured agent or unparseable path: the key is the session None => sessions.insert((key.clone(), String::new(), String::new())), @@ -207,7 +200,7 @@ impl Engine { if rec.versions.len() <= 1 { continue; } - let Some(rel) = self.wiremap.relative_of(&key).map(str::to_string) else { + let Some(rel) = self.filesystem.relative_of(&key).map(str::to_string) else { continue; }; let diverged = match self.divergence.cached(&key, &rec.versions) { @@ -248,7 +241,7 @@ impl Engine { /// it, so a settled key costs one lookup. Returns the relative path when /// it published. async fn merge_one(&self, key: &str) -> Result> { - let Some(rel) = self.wiremap.relative_of(key).map(str::to_string) else { + let Some(rel) = self.filesystem.relative_of(key).map(str::to_string) else { return Ok(None); }; let Some(rec) = self.node.index_record(key).await? else { @@ -288,51 +281,16 @@ impl Engine { } } - /// Snapshot every configured session dir as `reconcile` input. - fn local_snapshot(&mut self) -> Vec { - self.wiremap.begin_pass(); - let mut out = Vec::new(); - for path in self.wiremap.session_files() { - let Some(stamp) = file_stamp_micros(&path) else { - continue; - }; - let id = match self.wiremap.identify(&path) { - Ok(id) => id, - Err(e) => { - // per-key errors are logged, never silently dropped - if self.identify_logged.insert(path.clone()) { - eprintln!("ssync: skipping {}: {e:#}", path.display()); - } - continue; - } - }; - let key = match self.wiremap.key_of(&id, &path) { - KeyLookup::Key(key) => key, - KeyLookup::Skipped(announcement) => { - if let Some(error) = announcement { - eprintln!("ssync: skipping {}: {error:#}", path.display()); - } - continue; - } - }; - if self.wiremap.excluded(&key) { - continue; - } - out.push(LocalFile { key, path, stamp }); - } - out - } - /// Snapshot the synced index as `reconcile` input: winning entry plus the /// count of distinct live hashes (divergence) per key. async fn index_snapshot(&self) -> Result> { let mut out = std::collections::HashMap::new(); for rec in self.node.index_records().await? { let key = String::from_utf8(rec.key).context("index key not utf-8")?; - if self.wiremap.frozen(&key) { + if self.filesystem.frozen(&key) { continue; } - let merge_allowed = self.wiremap.append_only(&key); + let merge_allowed = self.filesystem.append_only(&key); out.insert( key, IndexEntry { @@ -368,7 +326,7 @@ impl Engine { } self.import_errors = 0; - let local = self.local_snapshot(); + let local = self.filesystem.snapshot(); let index = match self.index_snapshot().await { Ok(i) => i, Err(e) => { @@ -379,7 +337,7 @@ impl Engine { let mut changed = false; for action in reconcile(&self.state, &local, &index) { if let Action::Tombstone { key } = &action - && self.wiremap.tombstone_withheld(key) + && self.filesystem.tombstone_withheld(key) { continue; } @@ -447,51 +405,41 @@ impl Engine { } }, Action::WriteFile { key, hash } => { - // dest + on-disk bytes resolve through the path map after - // decryption (the header carries the canonical cwd) let ciphertext = match self.node.blob(*hash).await { - Ok(c) => c, - // per-key error, retried next tick like every other arm - Err(e) => { - eprintln!("ssync: fetch {key}: {e:#}"); + Ok(ciphertext) => ciphertext, + Err(error) => { + eprintln!("ssync: fetch {key}: {error:#}"); return false; } }; let plaintext = match self.identity.decrypt(&ciphertext).await { - Ok(p) => p, - Err(e) => { - eprintln!("ssync: decrypt {key}: {e:#}"); + Ok(plaintext) => plaintext, + Err(error) => { + eprintln!("ssync: decrypt {key}: {error:#}"); return false; } }; - let (dest, plaintext) = match self.wiremap.localize(key, plaintext) { - Ok(Some(pair)) => pair, - Ok(None) => return false, // unresolved yet — retried - Err(e) => { - eprintln!("ssync: write {key}: {e:#}"); - return false; + match self.filesystem.write(key, plaintext).await { + Ok(Some(stamp)) => { + self.state.settle_write(key, *hash, Some(stamp)); + true + } + Ok(None) => false, + Err(error) => { + eprintln!("ssync: write {key}: {error:#}"); + false } - }; - if let Err(e) = atomic_write(&dest, &plaintext).await { - eprintln!("ssync: write {}: {e:#}", dest.display()); - return false; } - self.state - .settle_write(key, *hash, file_stamp_micros(&dest)); - true } Action::DeleteLocal { key } => { self.state.settle_delete(key); - let Some(dest) = self.wiremap.local_dest_of(key) else { - return false; - }; - let existed = dest.exists(); - let _ = tokio::fs::remove_file(&dest).await; - // sweep the emptied artifact dir the deletion may leave behind - if let Some(root) = self.wiremap.session_root_of(key) { - cleanup::remove_empty_parents(&dest, root); + match self.filesystem.delete(key).await { + Ok(existed) => existed, + Err(error) => { + eprintln!("ssync: delete local {key}: {error:#}"); + false + } } - existed } Action::Tombstone { key } => match self.node.index_delete(key).await { Ok(()) => { @@ -544,7 +492,7 @@ impl Engine { let mut watcher = notify::recommended_watcher(move |res| { let _ = tx.send(res); })?; - for root in self.wiremap.roots() { + for root in self.filesystem.roots() { watcher .watch(root, RecursiveMode::Recursive) .with_context(|| format!("watching {}", root.display()))?; @@ -580,7 +528,7 @@ impl Engine { tokio::select! { Some(res) = rx.recv() => { if let Ok(event) = res - && event.paths.iter().any(|p| self.wiremap.is_session_file(p)) { + && event.paths.iter().any(|p| self.filesystem.is_session_file(p)) { deadline = Some(tokio::time::Instant::now() + DEBOUNCE); } } @@ -627,23 +575,6 @@ impl ImportOutcome { } } -/// Metadata stamp `(mtime_micros, len)` used to detect whether a file changed; -/// mtime is on the iroh-docs microsecond scale so it compares against index -/// timestamps directly (used for the resurrection guard in `reconcile`). -fn file_stamp_micros(path: &Path) -> Option<(u64, u64)> { - let m = std::fs::metadata(path).ok()?; - if !m.is_file() { - return None; - } - let mtime = m - .modified() - .ok()? - .duration_since(std::time::UNIX_EPOCH) - .ok()? - .as_micros() as u64; - Some((mtime, m.len())) -} - /// Conflicts to announce this pass: those in `current` not yet logged. The /// caller replaces its logged set with `current` afterwards, so a conflict /// that resolved and re-diverged announces again. @@ -658,25 +589,6 @@ fn newly_diverged<'a>( .collect() } -/// Write `data` to `dest` atomically: temp file in the same dir, then rename, so -/// a reader (the agent) never observes a partial file (DECISIONS §10). -async fn atomic_write(dest: &Path, data: &[u8]) -> Result<()> { - let parent = dest - .parent() - .ok_or_else(|| anyhow!("{} has no parent dir", dest.display()))?; - tokio::fs::create_dir_all(parent) - .await - .with_context(|| format!("creating {}", parent.display()))?; - let tmp: PathBuf = dest.with_extension("ssync-tmp"); - tokio::fs::write(&tmp, data) - .await - .with_context(|| format!("writing {}", tmp.display()))?; - tokio::fs::rename(&tmp, dest) - .await - .with_context(|| format!("renaming into {}", dest.display()))?; - Ok(()) -} - #[cfg(test)] mod tests { use super::*; @@ -708,10 +620,10 @@ mod tests { assert!(engine.tick_once().await, "first tick must import"); assert!(!engine.tick_once().await, "second tick must be a no-op"); - let id = engine.wiremap.identify(&session_path).unwrap(); - let KeyLookup::Key(key) = engine.wiremap.key_of(&id, &session_path) else { - panic!("session key lookup skipped"); - }; + let key = format!( + "pi/{}", + session_path.strip_prefix(&sessions_root).unwrap().display() + ); let rec = engine .node .index_record(key) diff --git a/crates/ssync-core/src/search.rs b/crates/ssync-core/src/search.rs index d484e0d..7be66b1 100644 --- a/crates/ssync-core/src/search.rs +++ b/crates/ssync-core/src/search.rs @@ -42,7 +42,9 @@ pub fn search(adapters: &[Box], query: &str, agent: Option<&str>) - if agent.is_some_and(|a| a != adapter.agent()) { continue; } - for path in crate::wiremap::session_files(adapter.session_root(), adapter.as_ref()) { + for path in + crate::session_filesystem::session_files(adapter.session_root(), adapter.as_ref()) + { let Ok(id) = adapter.identify(&path) else { continue; }; diff --git a/crates/ssync-core/src/session_filesystem.rs b/crates/ssync-core/src/session_filesystem.rs new file mode 100644 index 0000000..ef6f593 --- /dev/null +++ b/crates/ssync-core/src/session_filesystem.rs @@ -0,0 +1,744 @@ +//! Session filesystem policy: bounded discovery, wire-key ↔ local-path +//! translation, path-map resolution, freeze verdicts, and contained mutation. +//! Owns every adapter and every filesystem invariant around its session root. + +mod pathmap; + +pub use pathmap::PathMap; + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow, ensure}; +use ssync_adapters::{Adapter, SessionIdentity}; +use crate::reconcile::LocalFile; + +use crate::exclude; +use pathmap::Resolver; + + +/// Session discovery, translation, policy, and mutation for one engine. +pub(crate) struct SessionFilesystem { + adapters: Vec>, + /// Per-agent session exclusion patterns (issue #14); a matching key is + /// invisible to reconcile from both sides, freezing it everywhere. + excludes: HashMap>, + /// Wire↔local path-map translation (issue #13, map #42); default = inert. + resolver: Resolver, + resolved_roots: Vec>, + identify_logged: HashSet, +} + +impl SessionFilesystem { + pub fn new(adapters: Vec>) -> Self { + let root_count = adapters.len(); + Self { + adapters, + excludes: HashMap::new(), + resolver: Resolver::default(), + resolved_roots: (0..root_count).map(|_| None).collect(), + identify_logged: HashSet::new(), + } + } + + /// Per-agent `exclude` patterns from config (`[[agents]]` tables). + pub fn set_excludes(&mut self, excludes: HashMap>) { + self.excludes = excludes; + } + + /// The `[[path_map]]` + `canonical_home` from config (issue #13). + pub fn set_path_map(&mut self, map: PathMap, canonical_home: Option) { + self.resolver = Resolver::new(map, canonical_home); + } + + + /// Every configured session root (watch targets). + pub fn roots(&self) -> impl Iterator { + self.adapters.iter().map(|a| a.session_root()) + } + + /// Whether `path` is a session file of the adapter whose root contains it. + pub fn is_session_file(&self, path: &Path) -> bool { + self.adapter_of_path(path) + .is_some_and(|a| a.is_session_file(path)) + } + + + /// Decode an index key back to its session-root-relative path (the + /// inverse of the key encoding): strip the `{agent}/` prefix of a + /// configured adapter. + pub fn relative_of<'a>(&self, key: &'a str) -> Option<&'a str> { + self.key_parts(key).map(|(_, rel)| rel) + } + + /// The session identity an index key resolves to on this machine, if a + /// configured adapter can parse its destination path. + pub fn session_identity_of_key(&self, key: &str) -> Option { + let (idx, rel) = self.key_parts(key)?; + let adapter = self.adapters[idx].as_ref(); + adapter.identify(&adapter.session_root().join(rel)).ok() + } + + /// Whether a key falls under its agent's `exclude` patterns (issue #14). + /// Filtered out of BOTH reconcile inputs, so the key is frozen: never + /// imported, exported, tombstoned, or merged — here or on write-back. + pub fn excluded(&self, key: &str) -> bool { + let Some((idx, rel)) = self.key_parts(key) else { + return false; + }; + self.excluded_parts(self.adapters[idx].agent(), rel) + } + + /// Whether a key is invisible to reconcile: excluded (#14) or owned by no + /// configured adapter (dropped-agent guard — a removed `[[agents]]` entry + /// must never tombstone peers' sessions). + pub fn frozen(&self, key: &str) -> bool { + let Some((idx, rel)) = self.key_parts(key) else { + return true; + }; + self.excluded_parts(self.adapters[idx].agent(), rel) + } + + /// Whether a tombstone for this key must be withheld this pass: its + /// agent's mapping failed (#49), so "file gone" is an artifact of the + /// skip, not a delete. + pub fn tombstone_withheld(&self, key: &str) -> bool { + self.adapter_of_key(key) + .is_some_and(|a| self.resolver.agent_failed(a.agent())) + } + + /// Whether the key's format merges (append-only line union) rather than + /// newest-wins. `false` for keys of unconfigured agents. + pub fn append_only(&self, key: &str) -> bool { + self.adapter_of_key(key).is_some_and(|a| a.append_only()) + } + + /// The adapter owning an index key (matching `{agent}/` prefix), if any — + /// peers may sync agents this node does not have configured. + fn adapter_of_key(&self, key: &str) -> Option<&dyn Adapter> { + let (idx, _) = self.key_parts(key)?; + Some(self.adapters[idx].as_ref()) + } + + /// The adapter whose session root contains `path`. + fn adapter_of_path(&self, path: &Path) -> Option<&dyn Adapter> { + self.adapter_index_of_path(path) + .map(|i| self.adapters[i].as_ref()) + } + + fn adapter_index_of_path(&self, path: &Path) -> Option { + self.adapters + .iter() + .position(|a| path.starts_with(a.session_root())) + } + + fn key_parts<'a>(&self, key: &'a str) -> Option<(usize, &'a str)> { + self.adapters.iter().enumerate().find_map(|(idx, adapter)| { + key.strip_prefix(adapter.agent()) + .and_then(|rest| rest.strip_prefix('/')) + .map(|rel| (idx, rel)) + }) + } + + fn excluded_parts(&self, agent: &str, rel: &str) -> bool { + self.excludes + .get(agent) + .is_some_and(|patterns| exclude::is_excluded(patterns, rel)) + } + /// Snapshot every configured session root directly into reconcile input. + pub fn snapshot(&mut self) -> Vec { + self.resolver.begin_pass(); + let mut out = Vec::new(); + let resolver = &mut self.resolver; + let identify_logged = &mut self.identify_logged; + let excludes = &self.excludes; + for adapter in &self.adapters { + for_each_session_file(adapter.session_root(), adapter.as_ref(), |path| { + let Some(stamp) = file_stamp_micros(&path) else { + return; + }; + let id = match adapter.identify(&path) { + Ok(id) => id, + Err(error) => { + if identify_logged.insert(path.clone()) { + eprintln!("ssync: skipping {}: {error:#}", path.display()); + } + return; + } + }; + let rel = id.relative_path.to_string_lossy(); + let key = match resolver.wire_rel(adapter.as_ref(), &rel) { + Ok(None) => index_key(&id), + Ok(Some(mapped)) => format!("{}/{mapped}", id.agent), + Err(error) => { + let dir = path.parent().unwrap_or(&path).to_path_buf(); + if resolver.note_failure(&id.agent, dir) { + eprintln!("ssync: skipping {}: {error:#}", path.display()); + } + return; + } + }; + let wire_rel = key + .split_once('/') + .map(|(_, relative)| relative) + .unwrap_or_default(); + if excludes + .get(adapter.agent()) + .is_some_and(|patterns| exclude::is_excluded(patterns, wire_rel)) + { + return; + } + out.push(LocalFile { key, path, stamp }); + }); + } + out + } + + pub async fn read(&mut self, key: &str, path: &Path) -> Result> { + let (idx, _) = self + .key_parts(key) + .ok_or_else(|| anyhow!("{key}: no configured agent"))?; + let configured_root = self.adapters[idx].session_root().to_path_buf(); + let root = self.resolved_root(idx)?; + let path = contained_destination(&configured_root, &root, path)?; + let bytes = tokio::fs::read(&path) + .await + .with_context(|| format!("reading session file {}", path.display()))?; + self.resolver + .canonical_plaintext(self.adapters[idx].as_ref(), bytes) + } + + + /// Localize and atomically materialize one decrypted wire value. + pub async fn write(&mut self, key: &str, plaintext: Vec) -> Result> { + let Some((idx, rel)) = self.key_parts(key) else { + return Ok(None); + }; + validate_relative(rel)?; + let root = self.resolved_root(idx)?; + let adapter = self.adapters[idx].as_ref(); + let Some((dest, plaintext)) = self.resolver.localize(adapter, rel, plaintext)? else { + return Ok(None); + }; + let dest = contained_destination(adapter.session_root(), &root, &dest)?; + atomic_write(&dest, &plaintext).await?; + Ok(file_stamp_micros(&dest)) + } + + /// Delete one local wire value without following path-map or filesystem + /// links outside its configured session root. + pub async fn delete(&mut self, key: &str) -> Result { + let Some((idx, rel)) = self.key_parts(key) else { + return Ok(false); + }; + validate_relative(rel)?; + let root = self.resolved_root(idx)?; + let adapter = self.adapters[idx].as_ref(); + let Some(dest) = self.resolver.local_dest_of(adapter, rel) else { + return Ok(false); + }; + let dest = contained_destination(adapter.session_root(), &root, &dest)?; + let metadata = match tokio::fs::symlink_metadata(&dest).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(error).with_context(|| format!("reading {}", dest.display())); + } + }; + ensure!( + metadata.file_type().is_file() && !metadata.file_type().is_symlink(), + "{} is not a regular session file", + dest.display() + ); + tokio::fs::remove_file(&dest) + .await + .with_context(|| format!("removing {}", dest.display()))?; + crate::cleanup::remove_empty_parents(&dest, &root); + Ok(true) + } + + fn resolved_root(&mut self, idx: usize) -> Result { + if self.resolved_roots[idx].is_none() { + let root = self.adapters[idx].session_root(); + self.resolved_roots[idx] = Some( + root.canonicalize() + .with_context(|| format!("resolving session root {}", root.display()))?, + ); + } + Ok(self.resolved_roots[idx] + .as_ref() + .expect("resolved above") + .clone()) + } +} + +/// The iroh-docs index key for a session: `{agent}/{relative_path}`. The +/// relative path is machine-independent and carries the write-back location, +/// so the exporter can reconstruct where the file belongs on any peer. +fn index_key(id: &SessionIdentity) -> String { + format!("{}/{}", id.agent, id.relative_path.display()) +} + + +/// Recursively collect session files under `root` accepted by `adapter`. +pub(crate) fn session_files(root: &Path, adapter: &dyn Adapter) -> Vec { + let mut out = Vec::new(); + for_each_session_file(root, adapter, |path| out.push(path)); + out +} + +fn for_each_session_file( + root: &Path, + adapter: &dyn Adapter, + mut visit: impl FnMut(PathBuf), +) { + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let Ok(kind) = entry.file_type() else { + continue; + }; + if kind.is_symlink() { + continue; + } + let path = entry.path(); + if kind.is_dir() { + stack.push(path); + } else if kind.is_file() && adapter.is_session_file(&path) { + visit(path); + } + } + } +} + +fn validate_relative(rel: &str) -> Result<&Path> { + let path = Path::new(rel); + let mut has_component = false; + for component in path.components() { + ensure!( + matches!(component, std::path::Component::Normal(_)), + "{rel}: session path must be lexical and relative" + ); + has_component = true; + } + ensure!(has_component, "session path is empty"); + Ok(path) +} + +fn contained_destination(configured_root: &Path, root: &Path, dest: &Path) -> Result { + let relative = dest.strip_prefix(configured_root).with_context(|| { + format!( + "{} escapes session root {}", + dest.display(), + configured_root.display() + ) + })?; + for component in relative.components() { + ensure!( + matches!(component, std::path::Component::Normal(_)), + "{} is not a lexical child of {}", + dest.display(), + configured_root.display() + ); + } + let contained = root.join(relative); + let parent = contained + .parent() + .ok_or_else(|| anyhow!("{} has no parent dir", contained.display()))?; + let mut ancestor = root.to_path_buf(); + for component in parent + .strip_prefix(root) + .expect("contained parent") + .components() + { + ancestor.push(component); + match std::fs::symlink_metadata(&ancestor) { + Ok(metadata) => ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "{} is not a real directory", + ancestor.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, + Err(error) => { + return Err(error).with_context(|| format!("reading {}", ancestor.display())); + } + } + } + if let Ok(metadata) = std::fs::symlink_metadata(&contained) { + ensure!( + !metadata.file_type().is_symlink(), + "{} is a symlink", + contained.display() + ); + } + Ok(contained) +} + +fn file_stamp_micros(path: &Path) -> Option<(u64, u64)> { + let metadata = std::fs::metadata(path).ok()?; + if !metadata.is_file() { + return None; + } + let mtime = metadata + .modified() + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_micros() as u64; + Some((mtime, metadata.len())) +} + +async fn atomic_write(dest: &Path, data: &[u8]) -> Result<()> { + let parent = dest + .parent() + .ok_or_else(|| anyhow!("{} has no parent dir", dest.display()))?; + tokio::fs::create_dir_all(parent) + .await + .with_context(|| format!("creating {}", parent.display()))?; + let tmp = dest.with_extension("ssync-tmp"); + tokio::fs::write(&tmp, data) + .await + .with_context(|| format!("writing {}", tmp.display()))?; + tokio::fs::rename(&tmp, dest) + .await + .with_context(|| format!("renaming into {}", dest.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ssync_adapters::blob_store::BlobStoreAdapter; + use ssync_adapters::pi::PiAdapter; + + fn scratch(tag: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!("ssync-filesystem-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).unwrap(); + p + } + + fn pi_map(root: &Path) -> SessionFilesystem { + SessionFilesystem::new(vec![Box::new(PiAdapter::new("pi", root))]) + } + + + #[tokio::test] + async fn snapshot_and_write_round_trip_without_map() { + let root = scratch("roundtrip"); + let mut filesystem = pi_map(&root); + let rel = "--proj--/2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; + let key = format!("pi/{rel}"); + + filesystem.write(&key, b"session".to_vec()).await.unwrap(); + let snapshot = filesystem.snapshot(); + + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].key, key); + assert_eq!(filesystem.relative_of(&key), Some(rel)); + } + + #[test] + fn excluded_keys_are_frozen_but_foreign_agents_are_not_excluded() { + let root = scratch("exclude"); + let mut wm = pi_map(&root); + wm.set_excludes(HashMap::from([( + "pi".to_string(), + vec!["*secret*".to_string()], + )])); + assert!(wm.excluded("pi/--proj--/secret.jsonl")); + assert!(wm.frozen("pi/--proj--/secret.jsonl")); + assert!(!wm.excluded("pi/--proj--/s.jsonl")); + // patterns bind per agent; a key of an unconfigured agent is not + // excluded (it freezes via the dropped-agent guard instead) + assert!(!wm.excluded("ghost/--proj--/secret.jsonl")); + } + + #[test] + fn dropped_agent_keys_are_frozen() { + let root = scratch("dropped"); + let wm = pi_map(&root); + assert!(wm.frozen("ghost/--proj--/s.jsonl")); + assert!(!wm.frozen("pi/--proj--/s.jsonl")); + } + + #[test] + fn mapping_failure_withholds_tombstones_for_the_snapshot_pass() { + let root = scratch("freeze"); + let mut filesystem = pi_map(&root); + filesystem.set_path_map( + PathMap::new(vec![("/data".into(), "/canon".into())]).unwrap(), + None, + ); + let dir = root.join("--proj--"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join( + "2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl", + ); + std::fs::write(&path, b"{\"type\":\"session\",\"version\":3}\n").unwrap(); + + assert!(filesystem.snapshot().is_empty()); + assert!(filesystem.tombstone_withheld("pi/--proj--/other.jsonl")); + std::fs::remove_file(path).unwrap(); + assert!(filesystem.snapshot().is_empty()); + assert!(!filesystem.tombstone_withheld("pi/--proj--/other.jsonl")); + } + + #[tokio::test] + async fn mapped_session_round_trips_through_the_filesystem_interface() { + let root = scratch("mapped-roundtrip"); + let dir = root.join("--data-Projects-x--"); + std::fs::create_dir_all(&dir).unwrap(); + let name = + "2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; + let path = dir.join(name); + std::fs::write( + &path, + b"{\"type\":\"session\",\"version\":3,\"cwd\":\"/data/Projects/x\"}\n", + ) + .unwrap(); + let mut filesystem = pi_map(&root); + filesystem.set_path_map( + PathMap::new(vec![ + ("/data/Projects".into(), "/canon/Projects".into()), + ]) + .unwrap(), + None, + ); + + let snapshot = filesystem.snapshot(); + assert_eq!(snapshot.len(), 1); + let key = format!("pi/--canon-Projects-x--/{name}"); + assert_eq!(snapshot[0].key, key); + let wire_bytes = filesystem.read(&key, &path).await.unwrap(); + assert!(String::from_utf8_lossy(&wire_bytes).contains("\"cwd\":\"/canon/Projects/x\"")); + + std::fs::remove_file(&path).unwrap(); + filesystem.write(&key, wire_bytes).await.unwrap(); + let local_bytes = std::fs::read(path).unwrap(); + assert!(String::from_utf8_lossy(&local_bytes).contains("\"cwd\":\"/data/Projects/x\"")); + } + + #[test] + fn missing_omp_canonical_home_freezes_the_snapshot_pass() { + let root = scratch("omp-home"); + let dir = root.join("-Projects-x"); + std::fs::create_dir_all(&dir).unwrap(); + let name = + "2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; + std::fs::write( + dir.join(name), + b"{\"type\":\"session\",\"version\":3,\"cwd\":\"/data/Projects/x\"}\n", + ) + .unwrap(); + let mut filesystem = SessionFilesystem::new(vec![Box::new(PiAdapter::new( + "omp", &root, + ))]); + filesystem.set_path_map( + PathMap::new(vec![("/data".into(), "/canon-home".into())]).unwrap(), + None, + ); + + assert!(filesystem.snapshot().is_empty()); + assert!(filesystem.tombstone_withheld("omp/-Projects-x/other.jsonl")); + } + + #[test] + fn mapped_exclude_filters_the_wire_key_from_the_local_snapshot() { + let root = scratch("mapped-exclude"); + let dir = root.join("--data-Projects-x--"); + std::fs::create_dir_all(&dir).unwrap(); + let name = + "2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; + std::fs::write( + dir.join(name), + b"{\"type\":\"session\",\"version\":3,\"cwd\":\"/data/Projects/x\"}\n", + ) + .unwrap(); + let mut filesystem = pi_map(&root); + filesystem.set_path_map( + PathMap::new(vec![ + ("/data/Projects".into(), "/canon/Projects".into()), + ]) + .unwrap(), + None, + ); + filesystem.set_excludes(HashMap::from([( + "pi".to_string(), + vec!["*canon-Projects-x*".to_string()], + )])); + + assert!(filesystem.snapshot().is_empty()); + assert!(filesystem.frozen(&format!("pi/--canon-Projects-x--/{name}"))); + } + + #[test] + fn append_only_follows_the_owning_adapter() { + let root = scratch("append"); + let blob_root = scratch("append-blobs"); + let wm = SessionFilesystem::new(vec![ + Box::new(PiAdapter::new("pi", &root)), + Box::new(BlobStoreAdapter::new("omp-blobs", &blob_root)), + ]); + assert!(wm.append_only("pi/--proj--/s.jsonl")); + assert!(!wm.append_only("omp-blobs/abc123")); + assert!(!wm.append_only("ghost/x")); + } + + #[test] + fn is_session_file_requires_an_owning_root() { + let root = scratch("owning"); + let wm = pi_map(&root); + assert!(wm.is_session_file(&root.join("--proj--/s.jsonl"))); + assert!(!wm.is_session_file(Path::new("/elsewhere/--proj--/s.jsonl"))); + } + + #[test] + fn scan_resolves_root_symlink_but_skips_descendant_symlinks() { + use std::os::unix::fs::symlink; + + let base = scratch("symlinks"); + let real_root = base.join("real"); + let root = base.join("root"); + let outside = base.join("outside"); + std::fs::create_dir_all(real_root.join("--project--")).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(real_root.join("--project--/one_session.jsonl"), b"one").unwrap(); + std::fs::write(outside.join("outside_session.jsonl"), b"outside").unwrap(); + symlink(&real_root, &root).unwrap(); + symlink(&outside, real_root.join("--project--/linked-dir")).unwrap(); + symlink( + outside.join("outside_session.jsonl"), + real_root.join("--project--/linked-file.jsonl"), + ) + .unwrap(); + + let adapter = PiAdapter::new("pi", &root); + + assert_eq!( + session_files(&root, &adapter), + vec![root.join("--project--/one_session.jsonl")] + ); + } + + #[tokio::test] + async fn mapped_artifact_retries_until_main_session_learns_its_project_dir() { + let root = scratch("mapped-artifact"); + let mut filesystem = pi_map(&root); + filesystem.set_path_map( + PathMap::new(vec![("/data".into(), "/canon".into())]).unwrap(), + None, + ); + let main_key = + "pi/--canon-Projects-x--/2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; + let artifact_key = + "pi/--canon-Projects-x--/2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a/advisor.jsonl"; + + assert!( + filesystem + .write(artifact_key, b"{\"type\":\"advisor\"}\n".to_vec()) + .await + .unwrap() + .is_none() + ); + filesystem + .write( + main_key, + b"{\"type\":\"session\",\"version\":3,\"cwd\":\"/canon/Projects/x\"}\n" + .to_vec(), + ) + .await + .unwrap() + .expect("main session must establish the project translation"); + assert!( + filesystem + .write(artifact_key, b"{\"type\":\"advisor\"}\n".to_vec()) + .await + .unwrap() + .is_some() + ); + assert!( + root.join( + "--data-Projects-x--/2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a/advisor.jsonl" + ) + .exists() + ); + } + + #[test] + fn oversized_header_is_a_retriable_mapping_skip() { + let root = scratch("header-budget"); + let project = root.join("--project--"); + std::fs::create_dir_all(&project).unwrap(); + let name = + "2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; + let mut bytes = vec![b'x'; 64 * 1024]; + bytes.extend_from_slice(b"\n{\"type\":\"session\",\"cwd\":\"/work/project\"}\n"); + std::fs::write(project.join(name), bytes).unwrap(); + let mut filesystem = pi_map(&root); + filesystem.set_path_map( + PathMap::new(vec![("/work".into(), "/canonical".into())]).unwrap(), + None, + ); + + assert!(filesystem.snapshot().is_empty()); + assert!(filesystem.tombstone_withheld("pi/--project--/other.jsonl")); + } + + #[tokio::test] + async fn access_rejects_parent_components_and_symlinked_ancestors() { + use std::os::unix::fs::symlink; + + let root = scratch("contained-write"); + let outside = scratch("contained-write-outside"); + let mut filesystem = pi_map(&root); + + assert!( + filesystem + .write("pi/../escape.jsonl", b"escape".to_vec()) + .await + .is_err() + ); + std::fs::write(outside.join("session_id.jsonl"), b"outside").unwrap(); + symlink(&outside, root.join("--project--")).unwrap(); + assert!( + filesystem + .write( + "pi/--project--/session_id.jsonl", + b"escape".to_vec() + ) + .await + .is_err() + ); + assert!( + filesystem + .read( + "pi/--project--/session_id.jsonl", + &root.join("--project--/session_id.jsonl"), + ) + .await + .is_err() + ); + assert_eq!(std::fs::read(outside.join("session_id.jsonl")).unwrap(), b"outside"); + } + + #[tokio::test] + async fn write_and_delete_are_atomic_contained_mutations() { + let root = scratch("atomic-mutation"); + let mut filesystem = pi_map(&root); + let key = "pi/--project--/session_id.jsonl"; + + let stamp = filesystem + .write(key, b"session".to_vec()) + .await + .unwrap() + .expect("write must resolve"); + assert_eq!(std::fs::read(root.join("--project--/session_id.jsonl")).unwrap(), b"session"); + assert_eq!(stamp.1, 7); + assert!(filesystem.delete(key).await.unwrap()); + assert!(!root.join("--project--").exists()); + } +} diff --git a/crates/ssync-core/src/pathmap.rs b/crates/ssync-core/src/session_filesystem/pathmap.rs similarity index 71% rename from crates/ssync-core/src/pathmap.rs rename to crates/ssync-core/src/session_filesystem/pathmap.rs index 9d97236..429b5e3 100644 --- a/crates/ssync-core/src/pathmap.rs +++ b/crates/ssync-core/src/session_filesystem/pathmap.rs @@ -7,11 +7,14 @@ //! (#49). The engine consults the resolver; everything path-map lives here. use std::collections::{HashMap, HashSet}; +use std::io::Read; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow, bail, ensure}; use ssync_adapters::Adapter; +const HEADER_PREFIX_LIMIT: usize = 64 * 1024; + /// Validated prefix pairs. Invariants from construction: absolute prefixes, /// no trailing slash, no `/`, no duplicate local or canonical prefixes. #[derive(Debug, Clone, Default)] @@ -163,19 +166,40 @@ impl Resolver { return Ok(cached.clone()); } let mut local_cwd = None; + let mut oversized = None; for entry in std::fs::read_dir(project_dir) .with_context(|| format!("reading {}", project_dir.display()))? { - let p = entry?.path(); - if p.extension().and_then(|e| e.to_str()) != Some("jsonl") || !p.is_file() { + let entry = entry?; + let kind = entry.file_type()?; + let path = entry.path(); + if kind.is_symlink() + || !kind.is_file() + || path.extension().and_then(|extension| extension.to_str()) != Some("jsonl") + { continue; } - if let Ok(bytes) = std::fs::read(&p) - && let Some(cwd) = adapter.header_cwd(&bytes) - { + let file = std::fs::File::open(&path) + .with_context(|| format!("opening {}", path.display()))?; + let mut bytes = Vec::with_capacity(HEADER_PREFIX_LIMIT + 1); + file.take((HEADER_PREFIX_LIMIT + 1) as u64) + .read_to_end(&mut bytes) + .with_context(|| format!("reading header from {}", path.display()))?; + if let Some(cwd) = adapter.header_cwd(&bytes) { local_cwd = Some(cwd); break; } + if bytes.len() > HEADER_PREFIX_LIMIT { + oversized = Some(path); + } + } + if let Some(path) = oversized + && local_cwd.is_none() + { + bail!( + "{}: required session header exceeds 64 KiB", + path.display() + ); } let Some(local_cwd) = local_cwd else { bail!( @@ -452,171 +476,4 @@ mod tests { assert_eq!(m.local_of("/any").unwrap(), None); } - mod resolver { - use super::*; - use ssync_adapters::pi::PiAdapter; - - fn scratch(tag: &str) -> PathBuf { - let base = - std::env::temp_dir().join(format!("ssync-resolver-{}-{}", tag, std::process::id())); - let _ = std::fs::remove_dir_all(&base); - std::fs::create_dir_all(&base).unwrap(); - base - } - - fn header(cwd: &str) -> Vec { - format!( - "{{\"type\":\"session\",\"version\":3,\"id\":\"019e539d-f6ab-71ac-be20-d3ae2b23ea4a\",\"cwd\":\"{cwd}\"}}\n{{\"m\":\"1\"}}\n" - ) - .into_bytes() - } - - #[test] - fn empty_map_is_pass_through() { - let root = scratch("inert"); - let pi = PiAdapter::new("pi", &root); - let mut r = Resolver::default(); - assert_eq!(r.wire_rel(&pi, "--proj--/a.jsonl").unwrap(), None); - let bytes = header("/data/x"); - let (dest, out) = r - .localize(&pi, "--proj--/a.jsonl", bytes.clone()) - .unwrap() - .unwrap(); - assert_eq!(dest, root.join("--proj--/a.jsonl")); - assert_eq!(out, bytes); - assert_eq!( - r.local_dest_of(&pi, "--proj--/a.jsonl"), - Some(root.join("--proj--/a.jsonl")) - ); - assert_eq!(r.canonical_plaintext(&pi, bytes.clone()).unwrap(), bytes); - } - - #[test] - fn wire_rel_maps_project_dir_via_header_and_caches() { - let root = scratch("wire"); - let pi = PiAdapter::new("pi", &root); - let dir = root.join("--data-Projects-x--"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("s.jsonl"), header("/data/Projects/x")).unwrap(); - let mut r = Resolver::new(map(&[("/data/Projects", "/canon/Projects")]), None); - assert_eq!( - r.wire_rel(&pi, "--data-Projects-x--/s.jsonl") - .unwrap() - .as_deref(), - Some("--canon-Projects-x--/s.jsonl") - ); - // resolved once per dir: the mapping survives the dir's removal - std::fs::remove_dir_all(&dir).unwrap(); - assert_eq!( - r.wire_rel(&pi, "--data-Projects-x--/t.jsonl") - .unwrap() - .as_deref(), - Some("--canon-Projects-x--/t.jsonl") - ); - } - - #[test] - fn unmapped_cwd_is_pass_through() { - let root = scratch("passthrough"); - let pi = PiAdapter::new("pi", &root); - let dir = root.join("--elsewhere-y--"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("s.jsonl"), header("/elsewhere/y")).unwrap(); - let mut r = Resolver::new(map(&[("/data/Projects", "/canon/Projects")]), None); - assert_eq!(r.wire_rel(&pi, "--elsewhere-y--/s.jsonl").unwrap(), None); - } - - #[test] - fn missing_header_is_an_error_and_freezes_the_agent() { - let root = scratch("freeze"); - let pi = PiAdapter::new("pi", &root); - let dir = root.join("--proj--"); - std::fs::create_dir_all(&dir).unwrap(); // no session header inside - let mut r = Resolver::new(map(&[("/data", "/canon")]), None); - assert!(r.wire_rel(&pi, "--proj--/s.jsonl").is_err()); - assert!(r.note_failure("pi", dir.clone()), "first failure logs"); - assert!(!r.note_failure("pi", dir), "repeat failure stays quiet"); - assert!(r.agent_failed("pi")); - r.begin_pass(); - assert!(!r.agent_failed("pi"), "freeze lasts one pass"); - } - - #[test] - fn omp_without_canonical_home_is_an_error() { - let root = scratch("omp-home"); - let omp = PiAdapter::new("omp", &root); - let dir = root.join("-Projects-x"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("s.jsonl"), header("/data/Projects/x")).unwrap(); - let mut r = Resolver::new(map(&[("/data", "/canon-home")]), None); - let err = r.wire_rel(&omp, "-Projects-x/s.jsonl").unwrap_err(); - assert!(err.to_string().contains("canonical_home"), "{err:#}"); - } - - #[test] - fn localize_rewrites_header_and_learns_the_dir_translation() { - let root = scratch("localize"); - let pi = PiAdapter::new("pi", &root); - let mut r = Resolver::new(map(&[("/local/work", "/canon/Projects")]), None); - let (dest, bytes) = r - .localize( - &pi, - "--canon-Projects-x--/s.jsonl", - header("/canon/Projects/x"), - ) - .unwrap() - .unwrap(); - assert_eq!(dest, root.join("--local-work-x--/s.jsonl")); - assert_eq!(bytes, header("/local/work/x")); - - // header-less artifact bytes ride the learned translation ... - let art = b"{\"type\":\"advisor\"}\n".to_vec(); - let (art_dest, art_bytes) = r - .localize(&pi, "--canon-Projects-x--/art/a.jsonl", art.clone()) - .unwrap() - .unwrap(); - assert_eq!(art_dest, root.join("--local-work-x--/art/a.jsonl")); - assert_eq!(art_bytes, art); - // ... and so does DeleteLocal's dest lookup - assert_eq!( - r.local_dest_of(&pi, "--canon-Projects-x--/s.jsonl"), - Some(root.join("--local-work-x--/s.jsonl")) - ); - } - - #[test] - fn localize_artifact_of_unmaterialized_project_retries_later() { - let root = scratch("unresolved"); - let pi = PiAdapter::new("pi", &root); - let mut r = Resolver::new(map(&[("/local/work", "/canon/Projects")]), None); - // no translation learned yet: not resolvable, not an error - let out = r - .localize( - &pi, - "--canon-Projects-x--/art/a.jsonl", - b"no header\n".to_vec(), - ) - .unwrap(); - assert_eq!(out, None); - } - - #[test] - fn canonical_plaintext_maps_the_header_cwd() { - let root = scratch("canonical"); - let pi = PiAdapter::new("pi", &root); - let r = Resolver::new(map(&[("/local/work", "/canon/Projects")]), None); - assert_eq!( - r.canonical_plaintext(&pi, header("/local/work/x")).unwrap(), - header("/canon/Projects/x") - ); - // header-less artifact records pass through - let art = b"{\"type\":\"advisor\"}\n".to_vec(); - assert_eq!(r.canonical_plaintext(&pi, art.clone()).unwrap(), art); - // an unmapped cwd is already canonical - assert_eq!( - r.canonical_plaintext(&pi, header("/elsewhere/y")).unwrap(), - header("/elsewhere/y") - ); - } - } } diff --git a/crates/ssync-core/src/wiremap.rs b/crates/ssync-core/src/wiremap.rs deleted file mode 100644 index 1bde621..0000000 --- a/crates/ssync-core/src/wiremap.rs +++ /dev/null @@ -1,365 +0,0 @@ -//! The wire-map: every translation between wire keys (`{agent}/{relative_path}` -//! index keys) and local session paths, plus the freeze verdicts that keep a -//! skipped key from ever reading as a deletion. Owns the adapters, the -//! per-agent excludes (#14), and the path-map [`Resolver`] (#13/#49); the -//! engine holds one `Wiremap` and never touches adapters directly. - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use anyhow::{Result, anyhow}; -use ssync_adapters::{Adapter, SessionIdentity}; - -use crate::exclude; -use crate::pathmap::{PathMap, Resolver}; - -/// Result of resolving a local session to its wire key. -pub(crate) enum KeyLookup { - Key(String), - /// Retriable per-key skip (#49); `Some` is the first announcement. - Skipped(Option), -} - -/// Wire key ↔ local path translation and freeze state for one engine. -pub(crate) struct Wiremap { - adapters: Vec>, - /// Per-agent session exclusion patterns (issue #14); a matching key is - /// invisible to reconcile from both sides, freezing it everywhere. - excludes: HashMap>, - /// Wire↔local path-map translation (issue #13, map #42); default = inert. - resolver: Resolver, -} - -impl Wiremap { - pub fn new(adapters: Vec>) -> Self { - Self { - adapters, - excludes: HashMap::new(), - resolver: Resolver::default(), - } - } - - /// Per-agent `exclude` patterns from config (`[[agents]]` tables). - pub fn set_excludes(&mut self, excludes: HashMap>) { - self.excludes = excludes; - } - - /// The `[[path_map]]` + `canonical_home` from config (issue #13). - pub fn set_path_map(&mut self, map: PathMap, canonical_home: Option) { - self.resolver = Resolver::new(map, canonical_home); - } - - /// Start a snapshot pass: last pass's mapping failures no longer freeze. - pub fn begin_pass(&mut self) { - self.resolver.begin_pass(); - } - - /// Every configured session root (watch targets). - pub fn roots(&self) -> impl Iterator { - self.adapters.iter().map(|a| a.session_root()) - } - - /// Whether `path` is a session file of the adapter whose root contains it. - pub fn is_session_file(&self, path: &Path) -> bool { - self.adapter_of_path(path) - .is_some_and(|a| a.is_session_file(path)) - } - - /// Session files under every configured adapter's root. - pub fn session_files(&self) -> Vec { - self.adapters - .iter() - .flat_map(|a| session_files(a.session_root(), a.as_ref())) - .collect() - } - - /// Identify a path via the adapter whose session root contains it. - pub fn identify(&self, path: &Path) -> Result { - self.adapter_of_path(path) - .ok_or_else(|| anyhow!("{} is under no configured session root", path.display()))? - .identify(path) - } - - /// The wire key for a local file (issue #13): translated through the path - /// map when its project dir is mapped, today's relative path otherwise. - /// Mapping failures are retriable per-key skips (#49). - pub fn key_of(&mut self, id: &SessionIdentity, path: &Path) -> KeyLookup { - let Some(idx) = self.adapter_index_of_path(path) else { - return KeyLookup::Key(index_key(id)); - }; - let rel = id.relative_path.to_string_lossy(); - match self.resolver.wire_rel(self.adapters[idx].as_ref(), &rel) { - Ok(None) => KeyLookup::Key(index_key(id)), - Ok(Some(mapped)) => KeyLookup::Key(format!("{}/{mapped}", id.agent)), - Err(error) => { - let dir = path.parent().unwrap_or(path).to_path_buf(); - let announcement = self.resolver.note_failure(&id.agent, dir).then_some(error); - KeyLookup::Skipped(announcement) - } - } - } - - /// Local destination + on-disk bytes for a wire key's plaintext, through - /// the path map. `Ok(None)` = not resolvable yet — the caller returns - /// false and a later tick retries. - pub fn localize( - &mut self, - key: &str, - plaintext: Vec, - ) -> Result)>> { - let Some((idx, rel)) = self.key_parts(key) else { - return Ok(None); - }; - let adapter = self.adapters[idx].as_ref(); - self.resolver.localize(adapter, rel, plaintext) - } - - /// Local destination through the path map for actions without plaintext - /// in hand (DeleteLocal). `None` = nothing local to touch. - pub fn local_dest_of(&self, key: &str) -> Option { - let (idx, rel) = self.key_parts(key)?; - self.resolver - .local_dest_of(self.adapters[idx].as_ref(), rel) - } - - /// The wire form of a local file's bytes: header cwd mapped to canonical - /// (issue #13). A machine-local path must never reach the index — an - /// unmappable header is a per-key error, not a pass-through (#49). - pub fn canonical_plaintext(&self, key: &str, bytes: Vec) -> Result> { - let Some(adapter) = self.adapter_of_key(key) else { - return Ok(bytes); - }; - self.resolver.canonical_plaintext(adapter, bytes) - } - - /// The session root of the adapter owning `key` (empty-parent sweep after - /// DeleteLocal). - pub fn session_root_of(&self, key: &str) -> Option<&Path> { - self.adapter_of_key(key).map(|a| a.session_root()) - } - - /// Decode an index key back to its session-root-relative path (the - /// inverse of the key encoding): strip the `{agent}/` prefix of a - /// configured adapter. - pub fn relative_of<'a>(&self, key: &'a str) -> Option<&'a str> { - self.key_parts(key).map(|(_, rel)| rel) - } - - /// The session identity an index key resolves to on this machine, if a - /// configured adapter can parse its destination path. - pub fn session_identity_of_key(&self, key: &str) -> Option { - let (idx, rel) = self.key_parts(key)?; - let adapter = self.adapters[idx].as_ref(); - adapter.identify(&adapter.session_root().join(rel)).ok() - } - - /// Whether a key falls under its agent's `exclude` patterns (issue #14). - /// Filtered out of BOTH reconcile inputs, so the key is frozen: never - /// imported, exported, tombstoned, or merged — here or on write-back. - pub fn excluded(&self, key: &str) -> bool { - let Some((idx, rel)) = self.key_parts(key) else { - return false; - }; - self.excluded_parts(self.adapters[idx].agent(), rel) - } - - /// Whether a key is invisible to reconcile: excluded (#14) or owned by no - /// configured adapter (dropped-agent guard — a removed `[[agents]]` entry - /// must never tombstone peers' sessions). - pub fn frozen(&self, key: &str) -> bool { - let Some((idx, rel)) = self.key_parts(key) else { - return true; - }; - self.excluded_parts(self.adapters[idx].agent(), rel) - } - - /// Whether a tombstone for this key must be withheld this pass: its - /// agent's mapping failed (#49), so "file gone" is an artifact of the - /// skip, not a delete. - pub fn tombstone_withheld(&self, key: &str) -> bool { - self.adapter_of_key(key) - .is_some_and(|a| self.resolver.agent_failed(a.agent())) - } - - /// Whether the key's format merges (append-only line union) rather than - /// newest-wins. `false` for keys of unconfigured agents. - pub fn append_only(&self, key: &str) -> bool { - self.adapter_of_key(key).is_some_and(|a| a.append_only()) - } - - /// The adapter owning an index key (matching `{agent}/` prefix), if any — - /// peers may sync agents this node does not have configured. - fn adapter_of_key(&self, key: &str) -> Option<&dyn Adapter> { - let (idx, _) = self.key_parts(key)?; - Some(self.adapters[idx].as_ref()) - } - - /// The adapter whose session root contains `path`. - fn adapter_of_path(&self, path: &Path) -> Option<&dyn Adapter> { - self.adapter_index_of_path(path) - .map(|i| self.adapters[i].as_ref()) - } - - fn adapter_index_of_path(&self, path: &Path) -> Option { - self.adapters - .iter() - .position(|a| path.starts_with(a.session_root())) - } - - fn key_parts<'a>(&self, key: &'a str) -> Option<(usize, &'a str)> { - self.adapters.iter().enumerate().find_map(|(idx, adapter)| { - key.strip_prefix(adapter.agent()) - .and_then(|rest| rest.strip_prefix('/')) - .map(|rel| (idx, rel)) - }) - } - - fn excluded_parts(&self, agent: &str, rel: &str) -> bool { - self.excludes - .get(agent) - .is_some_and(|patterns| exclude::is_excluded(patterns, rel)) - } -} - -/// The iroh-docs index key for a session: `{agent}/{relative_path}`. The -/// relative path is machine-independent and carries the write-back location, -/// so the exporter can reconstruct where the file belongs on any peer. -fn index_key(id: &SessionIdentity) -> String { - format!("{}/{}", id.agent, id.relative_path.display()) -} - -/// Recursively collect session files under `root` accepted by `adapter`. -pub(crate) fn session_files(root: &Path, adapter: &dyn Adapter) -> Vec { - let mut out = Vec::new(); - let mut stack = vec![root.to_path_buf()]; - while let Some(dir) = stack.pop() { - let Ok(entries) = std::fs::read_dir(&dir) else { - continue; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - stack.push(path); - } else if adapter.is_session_file(&path) { - out.push(path); - } - } - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - use ssync_adapters::blob_store::BlobStoreAdapter; - use ssync_adapters::pi::PiAdapter; - - fn scratch(tag: &str) -> PathBuf { - let p = std::env::temp_dir().join(format!("ssync-wiremap-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&p); - std::fs::create_dir_all(&p).unwrap(); - p - } - - fn pi_map(root: &Path) -> Wiremap { - Wiremap::new(vec![Box::new(PiAdapter::new("pi", root))]) - } - - fn id(agent: &str, rel: &str) -> SessionIdentity { - SessionIdentity { - agent: agent.into(), - session_id: "s".into(), - project_id: "p".into(), - relative_path: rel.into(), - } - } - - #[test] - fn key_and_dest_round_trip_without_map() { - let root = scratch("roundtrip"); - let mut wm = pi_map(&root); - let rel = "--proj--/s.jsonl"; - let KeyLookup::Key(key) = wm.key_of(&id("pi", rel), &root.join(rel)) else { - panic!("key lookup skipped"); - }; - assert_eq!(key, format!("pi/{rel}")); - assert_eq!(wm.local_dest_of(&key), Some(root.join(rel))); - assert_eq!(wm.relative_of(&key), Some(rel)); - } - - #[test] - fn excluded_keys_are_frozen_but_foreign_agents_are_not_excluded() { - let root = scratch("exclude"); - let mut wm = pi_map(&root); - wm.set_excludes(HashMap::from([( - "pi".to_string(), - vec!["*secret*".to_string()], - )])); - assert!(wm.excluded("pi/--proj--/secret.jsonl")); - assert!(wm.frozen("pi/--proj--/secret.jsonl")); - assert!(!wm.excluded("pi/--proj--/s.jsonl")); - // patterns bind per agent; a key of an unconfigured agent is not - // excluded (it freezes via the dropped-agent guard instead) - assert!(!wm.excluded("ghost/--proj--/secret.jsonl")); - } - - #[test] - fn dropped_agent_keys_are_frozen() { - let root = scratch("dropped"); - let wm = pi_map(&root); - assert!(wm.frozen("ghost/--proj--/s.jsonl")); - assert!(!wm.frozen("pi/--proj--/s.jsonl")); - assert_eq!(wm.local_dest_of("ghost/--proj--/s.jsonl"), None); - } - - #[test] - fn mapping_failure_withholds_tombstones_and_announces_once() { - let root = scratch("freeze"); - let mut wm = pi_map(&root); - wm.set_path_map( - PathMap::new(vec![("/data".into(), "/canon".into())]).unwrap(), - None, - ); - // project dir exists but holds no session header to resolve the cwd - let dir = root.join("--proj--"); - std::fs::create_dir_all(&dir).unwrap(); - let rel = "--proj--/s.jsonl"; - let sid = id("pi", rel); - let path = root.join(rel); - - wm.begin_pass(); - let KeyLookup::Skipped(first) = wm.key_of(&sid, &path) else { - panic!("mapping unexpectedly succeeded"); - }; - assert!(first.is_some(), "first failure logs"); - assert!(wm.tombstone_withheld("pi/--proj--/other.jsonl")); - let KeyLookup::Skipped(second) = wm.key_of(&sid, &path) else { - panic!("mapping unexpectedly succeeded"); - }; - assert!(second.is_none(), "repeat failure stays quiet"); - // the freeze lasts one pass; the log-once memory does not reset - wm.begin_pass(); - assert!(!wm.tombstone_withheld("pi/--proj--/other.jsonl")); - } - - #[test] - fn append_only_follows_the_owning_adapter() { - let root = scratch("append"); - let blob_root = scratch("append-blobs"); - let wm = Wiremap::new(vec![ - Box::new(PiAdapter::new("pi", &root)), - Box::new(BlobStoreAdapter::new("omp-blobs", &blob_root)), - ]); - assert!(wm.append_only("pi/--proj--/s.jsonl")); - assert!(!wm.append_only("omp-blobs/abc123")); - assert!(!wm.append_only("ghost/x")); - } - - #[test] - fn is_session_file_requires_an_owning_root() { - let root = scratch("owning"); - let wm = pi_map(&root); - assert!(wm.is_session_file(&root.join("--proj--/s.jsonl"))); - assert!(!wm.is_session_file(Path::new("/elsewhere/--proj--/s.jsonl"))); - } -} From 59882c68ca7a5a9e5ac3edcfba4666ff4be21bfa Mon Sep 17 00:00:00 2001 From: fosskar <117449098+fosskar@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:44:18 +0000 Subject: [PATCH 4/6] core: contain atomic session writes --- crates/ssync-core/src/session_filesystem.rs | 190 +++++++++++------- .../src/session_filesystem/pathmap.rs | 6 +- 2 files changed, 113 insertions(+), 83 deletions(-) diff --git a/crates/ssync-core/src/session_filesystem.rs b/crates/ssync-core/src/session_filesystem.rs index ef6f593..e1e2877 100644 --- a/crates/ssync-core/src/session_filesystem.rs +++ b/crates/ssync-core/src/session_filesystem.rs @@ -9,14 +9,14 @@ pub use pathmap::PathMap; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use crate::reconcile::LocalFile; use anyhow::{Context, Result, anyhow, ensure}; use ssync_adapters::{Adapter, SessionIdentity}; -use crate::reconcile::LocalFile; +use tokio::io::AsyncWriteExt; use crate::exclude; use pathmap::Resolver; - /// Session discovery, translation, policy, and mutation for one engine. pub(crate) struct SessionFilesystem { adapters: Vec>, @@ -51,7 +51,6 @@ impl SessionFilesystem { self.resolver = Resolver::new(map, canonical_home); } - /// Every configured session root (watch targets). pub fn roots(&self) -> impl Iterator { self.adapters.iter().map(|a| a.session_root()) @@ -63,7 +62,6 @@ impl SessionFilesystem { .is_some_and(|a| a.is_session_file(path)) } - /// Decode an index key back to its session-root-relative path (the /// inverse of the key encoding): strip the `{agent}/` prefix of a /// configured adapter. @@ -208,7 +206,6 @@ impl SessionFilesystem { .canonical_plaintext(self.adapters[idx].as_ref(), bytes) } - /// Localize and atomically materialize one decrypted wire value. pub async fn write(&mut self, key: &str, plaintext: Vec) -> Result> { let Some((idx, rel)) = self.key_parts(key) else { @@ -279,7 +276,6 @@ fn index_key(id: &SessionIdentity) -> String { format!("{}/{}", id.agent, id.relative_path.display()) } - /// Recursively collect session files under `root` accepted by `adapter`. pub(crate) fn session_files(root: &Path, adapter: &dyn Adapter) -> Vec { let mut out = Vec::new(); @@ -287,11 +283,7 @@ pub(crate) fn session_files(root: &Path, adapter: &dyn Adapter) -> Vec out } -fn for_each_session_file( - root: &Path, - adapter: &dyn Adapter, - mut visit: impl FnMut(PathBuf), -) { +fn for_each_session_file(root: &Path, adapter: &dyn Adapter, mut visit: impl FnMut(PathBuf)) { let mut stack = vec![root.to_path_buf()]; while let Some(dir) = stack.pop() { let Ok(entries) = std::fs::read_dir(&dir) else { @@ -398,13 +390,44 @@ async fn atomic_write(dest: &Path, data: &[u8]) -> Result<()> { tokio::fs::create_dir_all(parent) .await .with_context(|| format!("creating {}", parent.display()))?; - let tmp = dest.with_extension("ssync-tmp"); - tokio::fs::write(&tmp, data) - .await - .with_context(|| format!("writing {}", tmp.display()))?; - tokio::fs::rename(&tmp, dest) - .await - .with_context(|| format!("renaming into {}", dest.display()))?; + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let mut attempt = 0_u64; + let (tmp, mut file) = loop { + let mut name = dest.as_os_str().to_os_string(); + name.push(format!( + ".ssync-tmp-{}-{nonce}-{attempt}", + std::process::id() + )); + let tmp = PathBuf::from(name); + match tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp) + .await + { + Ok(file) => break (tmp, file), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + attempt = attempt + .checked_add(1) + .ok_or_else(|| anyhow!("exhausted temporary names for {}", dest.display()))?; + } + Err(error) => { + return Err(error).with_context(|| format!("creating {}", tmp.display())); + } + } + }; + if let Err(error) = file.write_all(data).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(error).with_context(|| format!("writing {}", tmp.display())); + } + drop(file); + if let Err(error) = tokio::fs::rename(&tmp, dest).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(error).with_context(|| format!("renaming into {}", dest.display())); + } Ok(()) } @@ -421,15 +444,14 @@ mod tests { p } - fn pi_map(root: &Path) -> SessionFilesystem { + fn pi_filesystem(root: &Path) -> SessionFilesystem { SessionFilesystem::new(vec![Box::new(PiAdapter::new("pi", root))]) } - #[tokio::test] async fn snapshot_and_write_round_trip_without_map() { let root = scratch("roundtrip"); - let mut filesystem = pi_map(&root); + let mut filesystem = pi_filesystem(&root); let rel = "--proj--/2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; let key = format!("pi/{rel}"); @@ -444,40 +466,38 @@ mod tests { #[test] fn excluded_keys_are_frozen_but_foreign_agents_are_not_excluded() { let root = scratch("exclude"); - let mut wm = pi_map(&root); - wm.set_excludes(HashMap::from([( + let mut filesystem = pi_filesystem(&root); + filesystem.set_excludes(HashMap::from([( "pi".to_string(), vec!["*secret*".to_string()], )])); - assert!(wm.excluded("pi/--proj--/secret.jsonl")); - assert!(wm.frozen("pi/--proj--/secret.jsonl")); - assert!(!wm.excluded("pi/--proj--/s.jsonl")); + assert!(filesystem.excluded("pi/--proj--/secret.jsonl")); + assert!(filesystem.frozen("pi/--proj--/secret.jsonl")); + assert!(!filesystem.excluded("pi/--proj--/s.jsonl")); // patterns bind per agent; a key of an unconfigured agent is not // excluded (it freezes via the dropped-agent guard instead) - assert!(!wm.excluded("ghost/--proj--/secret.jsonl")); + assert!(!filesystem.excluded("ghost/--proj--/secret.jsonl")); } #[test] fn dropped_agent_keys_are_frozen() { let root = scratch("dropped"); - let wm = pi_map(&root); - assert!(wm.frozen("ghost/--proj--/s.jsonl")); - assert!(!wm.frozen("pi/--proj--/s.jsonl")); + let filesystem = pi_filesystem(&root); + assert!(filesystem.frozen("ghost/--proj--/s.jsonl")); + assert!(!filesystem.frozen("pi/--proj--/s.jsonl")); } #[test] fn mapping_failure_withholds_tombstones_for_the_snapshot_pass() { let root = scratch("freeze"); - let mut filesystem = pi_map(&root); + let mut filesystem = pi_filesystem(&root); filesystem.set_path_map( PathMap::new(vec![("/data".into(), "/canon".into())]).unwrap(), None, ); let dir = root.join("--proj--"); std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join( - "2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl", - ); + let path = dir.join("2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"); std::fs::write(&path, b"{\"type\":\"session\",\"version\":3}\n").unwrap(); assert!(filesystem.snapshot().is_empty()); @@ -492,20 +512,16 @@ mod tests { let root = scratch("mapped-roundtrip"); let dir = root.join("--data-Projects-x--"); std::fs::create_dir_all(&dir).unwrap(); - let name = - "2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; + let name = "2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; let path = dir.join(name); std::fs::write( &path, b"{\"type\":\"session\",\"version\":3,\"cwd\":\"/data/Projects/x\"}\n", ) .unwrap(); - let mut filesystem = pi_map(&root); + let mut filesystem = pi_filesystem(&root); filesystem.set_path_map( - PathMap::new(vec![ - ("/data/Projects".into(), "/canon/Projects".into()), - ]) - .unwrap(), + PathMap::new(vec![("/data/Projects".into(), "/canon/Projects".into())]).unwrap(), None, ); @@ -527,16 +543,13 @@ mod tests { let root = scratch("omp-home"); let dir = root.join("-Projects-x"); std::fs::create_dir_all(&dir).unwrap(); - let name = - "2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; + let name = "2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; std::fs::write( dir.join(name), b"{\"type\":\"session\",\"version\":3,\"cwd\":\"/data/Projects/x\"}\n", ) .unwrap(); - let mut filesystem = SessionFilesystem::new(vec![Box::new(PiAdapter::new( - "omp", &root, - ))]); + let mut filesystem = SessionFilesystem::new(vec![Box::new(PiAdapter::new("omp", &root))]); filesystem.set_path_map( PathMap::new(vec![("/data".into(), "/canon-home".into())]).unwrap(), None, @@ -551,19 +564,15 @@ mod tests { let root = scratch("mapped-exclude"); let dir = root.join("--data-Projects-x--"); std::fs::create_dir_all(&dir).unwrap(); - let name = - "2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; + let name = "2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; std::fs::write( dir.join(name), b"{\"type\":\"session\",\"version\":3,\"cwd\":\"/data/Projects/x\"}\n", ) .unwrap(); - let mut filesystem = pi_map(&root); + let mut filesystem = pi_filesystem(&root); filesystem.set_path_map( - PathMap::new(vec![ - ("/data/Projects".into(), "/canon/Projects".into()), - ]) - .unwrap(), + PathMap::new(vec![("/data/Projects".into(), "/canon/Projects".into())]).unwrap(), None, ); filesystem.set_excludes(HashMap::from([( @@ -579,21 +588,21 @@ mod tests { fn append_only_follows_the_owning_adapter() { let root = scratch("append"); let blob_root = scratch("append-blobs"); - let wm = SessionFilesystem::new(vec![ + let filesystem = SessionFilesystem::new(vec![ Box::new(PiAdapter::new("pi", &root)), Box::new(BlobStoreAdapter::new("omp-blobs", &blob_root)), ]); - assert!(wm.append_only("pi/--proj--/s.jsonl")); - assert!(!wm.append_only("omp-blobs/abc123")); - assert!(!wm.append_only("ghost/x")); + assert!(filesystem.append_only("pi/--proj--/s.jsonl")); + assert!(!filesystem.append_only("omp-blobs/abc123")); + assert!(!filesystem.append_only("ghost/x")); } #[test] fn is_session_file_requires_an_owning_root() { let root = scratch("owning"); - let wm = pi_map(&root); - assert!(wm.is_session_file(&root.join("--proj--/s.jsonl"))); - assert!(!wm.is_session_file(Path::new("/elsewhere/--proj--/s.jsonl"))); + let filesystem = pi_filesystem(&root); + assert!(filesystem.is_session_file(&root.join("--proj--/s.jsonl"))); + assert!(!filesystem.is_session_file(Path::new("/elsewhere/--proj--/s.jsonl"))); } #[test] @@ -627,15 +636,13 @@ mod tests { #[tokio::test] async fn mapped_artifact_retries_until_main_session_learns_its_project_dir() { let root = scratch("mapped-artifact"); - let mut filesystem = pi_map(&root); + let mut filesystem = pi_filesystem(&root); filesystem.set_path_map( PathMap::new(vec![("/data".into(), "/canon".into())]).unwrap(), None, ); - let main_key = - "pi/--canon-Projects-x--/2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; - let artifact_key = - "pi/--canon-Projects-x--/2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a/advisor.jsonl"; + let main_key = "pi/--canon-Projects-x--/2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; + let artifact_key = "pi/--canon-Projects-x--/2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a/advisor.jsonl"; assert!( filesystem @@ -647,8 +654,7 @@ mod tests { filesystem .write( main_key, - b"{\"type\":\"session\",\"version\":3,\"cwd\":\"/canon/Projects/x\"}\n" - .to_vec(), + b"{\"type\":\"session\",\"version\":3,\"cwd\":\"/canon/Projects/x\"}\n".to_vec(), ) .await .unwrap() @@ -673,12 +679,11 @@ mod tests { let root = scratch("header-budget"); let project = root.join("--project--"); std::fs::create_dir_all(&project).unwrap(); - let name = - "2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; + let name = "2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; let mut bytes = vec![b'x'; 64 * 1024]; bytes.extend_from_slice(b"\n{\"type\":\"session\",\"cwd\":\"/work/project\"}\n"); std::fs::write(project.join(name), bytes).unwrap(); - let mut filesystem = pi_map(&root); + let mut filesystem = pi_filesystem(&root); filesystem.set_path_map( PathMap::new(vec![("/work".into(), "/canonical".into())]).unwrap(), None, @@ -694,7 +699,7 @@ mod tests { let root = scratch("contained-write"); let outside = scratch("contained-write-outside"); - let mut filesystem = pi_map(&root); + let mut filesystem = pi_filesystem(&root); assert!( filesystem @@ -706,10 +711,7 @@ mod tests { symlink(&outside, root.join("--project--")).unwrap(); assert!( filesystem - .write( - "pi/--project--/session_id.jsonl", - b"escape".to_vec() - ) + .write("pi/--project--/session_id.jsonl", b"escape".to_vec()) .await .is_err() ); @@ -722,13 +724,42 @@ mod tests { .await .is_err() ); - assert_eq!(std::fs::read(outside.join("session_id.jsonl")).unwrap(), b"outside"); + assert_eq!( + std::fs::read(outside.join("session_id.jsonl")).unwrap(), + b"outside" + ); + } + + #[tokio::test] + async fn write_does_not_follow_existing_temporary_symlink() { + use std::os::unix::fs::symlink; + + let root = scratch("temporary-symlink"); + let outside = scratch("temporary-symlink-outside"); + let project = root.join("--project--"); + std::fs::create_dir_all(&project).unwrap(); + let outside_file = outside.join("user-file"); + std::fs::write(&outside_file, b"outside").unwrap(); + symlink(&outside_file, project.join("session_id.ssync-tmp")).unwrap(); + let mut filesystem = pi_filesystem(&root); + + filesystem + .write("pi/--project--/session_id.jsonl", b"session".to_vec()) + .await + .unwrap() + .expect("write must resolve"); + + assert_eq!(std::fs::read(outside_file).unwrap(), b"outside"); + assert_eq!( + std::fs::read(project.join("session_id.jsonl")).unwrap(), + b"session" + ); } #[tokio::test] async fn write_and_delete_are_atomic_contained_mutations() { let root = scratch("atomic-mutation"); - let mut filesystem = pi_map(&root); + let mut filesystem = pi_filesystem(&root); let key = "pi/--project--/session_id.jsonl"; let stamp = filesystem @@ -736,7 +767,10 @@ mod tests { .await .unwrap() .expect("write must resolve"); - assert_eq!(std::fs::read(root.join("--project--/session_id.jsonl")).unwrap(), b"session"); + assert_eq!( + std::fs::read(root.join("--project--/session_id.jsonl")).unwrap(), + b"session" + ); assert_eq!(stamp.1, 7); assert!(filesystem.delete(key).await.unwrap()); assert!(!root.join("--project--").exists()); diff --git a/crates/ssync-core/src/session_filesystem/pathmap.rs b/crates/ssync-core/src/session_filesystem/pathmap.rs index 429b5e3..2021840 100644 --- a/crates/ssync-core/src/session_filesystem/pathmap.rs +++ b/crates/ssync-core/src/session_filesystem/pathmap.rs @@ -196,10 +196,7 @@ impl Resolver { if let Some(path) = oversized && local_cwd.is_none() { - bail!( - "{}: required session header exceeds 64 KiB", - path.display() - ); + bail!("{}: required session header exceeds 64 KiB", path.display()); } let Some(local_cwd) = local_cwd else { bail!( @@ -475,5 +472,4 @@ mod tests { assert_eq!(m.canonical_of("/any").unwrap(), None); assert_eq!(m.local_of("/any").unwrap(), None); } - } From 699ab2a7ae159814c4a3222a553d6a9ec4ca4caf Mon Sep 17 00:00:00 2001 From: fosskar <117449098+fosskar@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:07:07 +0000 Subject: [PATCH 5/6] crypto: complete subprocess lifetime bounds --- crates/ssync-core/src/lib.rs | 14 +- crates/ssync-core/tests/event_flood.rs | 4 +- crates/ssync-core/tests/harness/mod.rs | 12 +- crates/ssync-core/tests/multi_node_sync.rs | 155 +++--- crates/ssync-core/tests/recipient_rotation.rs | 17 +- crates/ssync-crypto/src/lib.rs | 445 +++++++++++++----- crates/ssync/src/cluster.rs | 4 +- crates/ssync/src/main.rs | 16 +- 8 files changed, 444 insertions(+), 223 deletions(-) diff --git a/crates/ssync-core/src/lib.rs b/crates/ssync-core/src/lib.rs index 65f06fc..21b540f 100644 --- a/crates/ssync-core/src/lib.rs +++ b/crates/ssync-core/src/lib.rs @@ -613,7 +613,7 @@ mod tests { node.create_namespace().await.unwrap(); let mut engine = Engine::new( PiAdapter::new("pi", &sessions_root), - AgeIdentity::generate().unwrap(), + AgeIdentity::generate().await.unwrap(), node, ); @@ -645,7 +645,7 @@ mod tests { .unwrap(); let engine = Engine::new( PiAdapter::new("pi", base.join("sessions")), - AgeIdentity::generate().unwrap(), + AgeIdentity::generate().await.unwrap(), node, ); let v1 = engine.identity.encrypt(b"h\na\n").await.unwrap(); @@ -672,7 +672,7 @@ mod tests { .unwrap(); let engine = Engine::new( PiAdapter::new("pi", base.join("sessions")), - AgeIdentity::generate().unwrap(), + AgeIdentity::generate().await.unwrap(), node, ); let h1 = engine @@ -718,7 +718,7 @@ mod tests { .unwrap(); let engine = Engine::new( PiAdapter::new("pi", base.join("sessions")), - AgeIdentity::generate().unwrap(), + AgeIdentity::generate().await.unwrap(), node, ); let report = engine.status_report().await.unwrap(); @@ -760,7 +760,7 @@ mod tests { node.create_namespace().await.unwrap(); let mut engine = Engine::new( PiAdapter::new("pi", &sessions_root), - AgeIdentity::generate().unwrap(), + AgeIdentity::generate().await.unwrap(), node, ); @@ -798,7 +798,7 @@ mod tests { node.create_namespace().await.unwrap(); let mut engine = Engine::new( PiAdapter::new("pi", &sessions_root), - AgeIdentity::generate().unwrap(), + AgeIdentity::generate().await.unwrap(), node, ); assert!(engine.tick_once().await, "tick must import"); @@ -821,7 +821,7 @@ mod tests { node.create_namespace().await.unwrap(); let mut engine = Engine::new( PiAdapter::new("pi", base.join("sessions")), - AgeIdentity::generate().unwrap(), + AgeIdentity::generate().await.unwrap(), node, ); let state_path = base.join("missing-dir/state.toml"); diff --git a/crates/ssync-core/tests/event_flood.rs b/crates/ssync-core/tests/event_flood.rs index 72083b1..1b4bbd8 100644 --- a/crates/ssync-core/tests/event_flood.rs +++ b/crates/ssync-core/tests/event_flood.rs @@ -16,10 +16,10 @@ use harness::*; #[tokio::test(flavor = "multi_thread")] async fn initial_import_of_many_sessions_does_not_wedge() { - let sim = Sim::new("event-flood"); + let sim = Sim::new("event-flood").await; let mut node = sim.node("n").await; node.create_namespace().await.unwrap(); - let peer = sim.pi_peer("n", "pi", node); + let peer = sim.pi_peer("n", "pi", node).await; for i in 0..1500u32 { peer.write( format!( diff --git a/crates/ssync-core/tests/harness/mod.rs b/crates/ssync-core/tests/harness/mod.rs index dd82c00..1cebdf2 100644 --- a/crates/ssync-core/tests/harness/mod.rs +++ b/crates/ssync-core/tests/harness/mod.rs @@ -24,19 +24,19 @@ pub struct Sim { } impl Sim { - pub fn new(tag: &str) -> Self { + pub async fn new(tag: &str) -> Self { let base = std::env::temp_dir().join(format!("ssync-{}-{}", tag, std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); Self { base, - secret: AgeIdentity::generate().unwrap().to_secret_string(), + secret: AgeIdentity::generate().await.unwrap().to_secret_string(), } } /// The shared identity (a fresh instance per call — `Engine` takes it by value). - pub fn identity(&self) -> AgeIdentity { - AgeIdentity::from_secret_string(&self.secret).unwrap() + pub async fn identity(&self) -> AgeIdentity { + AgeIdentity::from_secret_string(&self.secret).await.unwrap() } /// Spawn `name`'s node at `base/{name}/data` with a fresh key. @@ -59,8 +59,8 @@ impl Sim { } /// A peer running one pi-layout agent on the shared identity. - pub fn pi_peer(&self, name: &str, agent: &str, node: Node) -> Peer { - self.pi_peer_as(name, agent, self.identity(), node) + pub async fn pi_peer(&self, name: &str, agent: &str, node: Node) -> Peer { + self.pi_peer_as(name, agent, self.identity().await, node) } /// Same, with a per-machine identity (multi-recipient tests). diff --git a/crates/ssync-core/tests/multi_node_sync.rs b/crates/ssync-core/tests/multi_node_sync.rs index 6c67155..c8ce90d 100644 --- a/crates/ssync-core/tests/multi_node_sync.rs +++ b/crates/ssync-core/tests/multi_node_sync.rs @@ -20,7 +20,7 @@ use ssync_net::iroh::SecretKey; #[tokio::test] async fn session_created_on_a_appears_on_b() { - let sim = Sim::new("base"); + let sim = Sim::new("base").await; // --- node A: has a real session file --- let rel = "--home-simon-Projects-demo--/2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; @@ -29,14 +29,14 @@ async fn session_created_on_a_appears_on_b() { let mut node_a = sim.node("a").await; node_a.create_namespace().await.unwrap(); let ticket = node_a.share().await.unwrap(); - let mut peer_a = sim.pi_peer("a", "pi", node_a); + let mut peer_a = sim.pi_peer("a", "pi", node_a).await; peer_a.write(rel, contents); peer_a.tick().await; // --- node B: empty session dir, joins A's namespace --- let mut node_b = sim.node("b").await; node_b.join(ticket).await.unwrap(); - let mut peer_b = sim.pi_peer("b", "pi", node_b); + let mut peer_b = sim.pi_peer("b", "pi", node_b).await; // sync is async: tick B until the file materializes, byte-identical. let dest = peer_b.path(rel); @@ -46,9 +46,9 @@ async fn session_created_on_a_appears_on_b() { #[tokio::test] async fn per_machine_identities_sync_both_directions() { - let sim = Sim::new("permachine"); - let id_a = AgeIdentity::generate().unwrap(); - let id_b = AgeIdentity::generate().unwrap(); + let sim = Sim::new("permachine").await; + let id_a = AgeIdentity::generate().await.unwrap(); + let id_b = AgeIdentity::generate().await.unwrap(); // --- node A: own key, B listed as recipient, has a session --- let rel_a = "--home-simon-Projects-demo--/2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4b.jsonl"; @@ -57,7 +57,9 @@ async fn per_machine_identities_sync_both_directions() { let mut node_a = sim.node("a").await; node_a.create_namespace().await.unwrap(); let ticket = node_a.share().await.unwrap(); - let mut ident_a = AgeIdentity::from_secret_string(&id_a.to_secret_string()).unwrap(); + let mut ident_a = AgeIdentity::from_secret_string(&id_a.to_secret_string()) + .await + .unwrap(); ident_a.add_recipients([id_b.recipient_string()]); let mut peer_a = sim.pi_peer_as("a", "pi", ident_a, node_a); peer_a.write(rel_a, contents_a); @@ -66,7 +68,9 @@ async fn per_machine_identities_sync_both_directions() { // --- node B: own key, A listed as recipient --- let mut node_b = sim.node("b").await; node_b.join(ticket).await.unwrap(); - let mut ident_b = AgeIdentity::from_secret_string(&id_b.to_secret_string()).unwrap(); + let mut ident_b = AgeIdentity::from_secret_string(&id_b.to_secret_string()) + .await + .unwrap(); ident_b.add_recipients([id_a.recipient_string()]); let mut peer_b = sim.pi_peer_as("b", "pi", ident_b, node_b); @@ -93,14 +97,21 @@ async fn per_machine_identities_reach_a_third_machine() { // every machine must encrypt to *all* peers. three nodes, full recipient // mesh, one shared namespace — a session from A must land on B and C, and // one from C must land on A and B. - let sim = Sim::new("threenode"); - let ids: Vec = (0..3).map(|_| AgeIdentity::generate().unwrap()).collect(); - let recipients: Vec = ids.iter().map(|i| i.recipient_string()).collect(); - let ident = |n: usize| { - let mut id = AgeIdentity::from_secret_string(&ids[n].to_secret_string()).unwrap(); - id.add_recipients(recipients.clone()); - id - }; + let sim = Sim::new("threenode").await; + let mut ids = Vec::with_capacity(3); + for _ in 0..3 { + ids.push(AgeIdentity::generate().await.unwrap()); + } + let recipients: Vec = ids.iter().map(AgeIdentity::recipient_string).collect(); + let mut identities = Vec::with_capacity(3); + for id in &ids { + let mut identity = AgeIdentity::from_secret_string(&id.to_secret_string()) + .await + .unwrap(); + identity.add_recipients(recipients.clone()); + identities.push(identity); + } + let mut identities = identities.into_iter(); let rel_a = "--home-simon-Projects-demo--/2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4d.jsonl"; let contents_a = b"{\"type\":\"session\",\"version\":3}\n{\"msg\":\"from A\"}\n"; @@ -109,17 +120,17 @@ async fn per_machine_identities_reach_a_third_machine() { node_a.create_namespace().await.unwrap(); let ticket_b = node_a.share().await.unwrap(); let ticket_c = node_a.share().await.unwrap(); - let mut peer_a = sim.pi_peer_as("a", "pi", ident(0), node_a); + let mut peer_a = sim.pi_peer_as("a", "pi", identities.next().unwrap(), node_a); peer_a.write(rel_a, contents_a); peer_a.tick().await; let mut node_b = sim.node("b").await; node_b.join(ticket_b).await.unwrap(); - let mut peer_b = sim.pi_peer_as("b", "pi", ident(1), node_b); + let mut peer_b = sim.pi_peer_as("b", "pi", identities.next().unwrap(), node_b); let mut node_c = sim.node("c").await; node_c.join(ticket_c).await.unwrap(); - let mut peer_c = sim.pi_peer_as("c", "pi", ident(2), node_c); + let mut peer_c = sim.pi_peer_as("c", "pi", identities.next().unwrap(), node_c); // A → B and A → C: both peers decrypt A's blob with their own keys. let dest_b = peer_b.path(rel_a); @@ -145,16 +156,16 @@ async fn per_machine_identities_reach_a_third_machine() { #[tokio::test] async fn live_write_propagates_without_restart() { - let sim = Sim::new("live"); + let sim = Sim::new("live").await; let mut node_a = sim.node("a").await; node_a.create_namespace().await.unwrap(); let ticket = node_a.share().await.unwrap(); - let peer_a = sim.pi_peer("a", "pi", node_a); + let peer_a = sim.pi_peer("a", "pi", node_a).await; let mut node_b = sim.node("b").await; node_b.join(ticket).await.unwrap(); - let peer_b = sim.pi_peer("b", "pi", node_b); + let peer_b = sim.pi_peer("b", "pi", node_b).await; // start both daemons and let them enter their loops let root_a = peer_a.run(); @@ -173,7 +184,7 @@ async fn live_write_propagates_without_restart() { #[tokio::test] async fn shared_namespace_auto_connects_without_ticket() { - let sim = Sim::new("shared"); + let sim = Sim::new("shared").await; let ns_secret = ssync_net::generate_key_bytes(); let mut node_a = sim.node("a").await; @@ -190,8 +201,8 @@ async fn shared_namespace_auto_connects_without_ticket() { node_a.sync_with(vec![addr_b]).await.unwrap(); node_b.sync_with(vec![addr_a]).await.unwrap(); - let peer_a = sim.pi_peer("a", "pi", node_a); - let peer_b = sim.pi_peer("b", "pi", node_b); + let peer_a = sim.pi_peer("a", "pi", node_a).await; + let peer_b = sim.pi_peer("b", "pi", node_b).await; let root_a = peer_a.run(); let root_b = peer_b.run(); @@ -215,13 +226,13 @@ async fn shared_namespace_auto_connects_without_ticket() { #[tokio::test] async fn deletion_propagates_and_does_not_resurrect() { - let sim = Sim::new("del"); + let sim = Sim::new("del").await; let rel = "--proj--/2026-01-01T00-00-00-000Z_019edddd0001eeee71acbe20delete001.jsonl"; let mut node_a = sim.node("a").await; node_a.create_namespace().await.unwrap(); let ticket = node_a.share().await.unwrap(); - let peer_a = sim.pi_peer("a", "pi", node_a); + let peer_a = sim.pi_peer("a", "pi", node_a).await; // keep a second unrelated session so the dir is never empty (deletion guard) peer_a.write( "--proj--/keep_019e0000keepkeepkeep71acbe20keep00001.jsonl", @@ -230,7 +241,7 @@ async fn deletion_propagates_and_does_not_resurrect() { let mut node_b = sim.node("b").await; node_b.join(ticket).await.unwrap(); - let peer_b = sim.pi_peer("b", "pi", node_b); + let peer_b = sim.pi_peer("b", "pi", node_b).await; peer_a.write(rel, b"header\nto-be-deleted\n"); // the session's artifact dir (omp subagent transcript) syncs and must be @@ -272,13 +283,13 @@ async fn deletion_propagates_and_does_not_resurrect() { async fn deletion_by_non_author_propagates_back() { // a session created on A, deleted on B, must disappear on A (and stay gone) // even though A authored the index entry (TODO "deletion by any participant"). - let sim = Sim::new("xdel"); + let sim = Sim::new("xdel").await; let rel = "--proj--/2026-01-01T00-00-00-000Z_019exdel0001eeee71acbe20xdele001.jsonl"; let mut node_a = sim.node("a").await; node_a.create_namespace().await.unwrap(); let ticket = node_a.share().await.unwrap(); - let peer_a = sim.pi_peer("a", "pi", node_a); + let peer_a = sim.pi_peer("a", "pi", node_a).await; // second session so neither dir ever goes empty (deletion guard) peer_a.write( "--proj--/keep_019e0000keepkeepkeep71acbe20keep00001.jsonl", @@ -288,7 +299,7 @@ async fn deletion_by_non_author_propagates_back() { let mut node_b = sim.node("b").await; node_b.join(ticket).await.unwrap(); - let peer_b = sim.pi_peer("b", "pi", node_b); + let peer_b = sim.pi_peer("b", "pi", node_b).await; let root_a = peer_a.run(); let root_b = peer_b.run(); @@ -320,7 +331,7 @@ async fn deletion_while_daemon_down_is_not_reimported() { // engine 1 imports two sessions and persists its state; one file is // deleted "while the daemon is down"; engine 2 (same state file, same // node dir) must tombstone the deleted session instead of re-importing it. - let sim = Sim::new("down-del"); + let sim = Sim::new("down-del").await; let rel = "--proj--/2026-01-01T00-00-00-000Z_019edown0001eeee71acbe20downdel01.jsonl"; let keep = "--proj--/keep_019e0000keepkeepkeep71acbe20keep00001.jsonl"; let state_path = sim.base.join("state.toml"); @@ -328,7 +339,7 @@ async fn deletion_while_daemon_down_is_not_reimported() { let ns = { let mut node = sim.node("n").await; let ns = node.create_namespace().await.unwrap(); - let mut peer = sim.pi_peer("n", "pi", node); + let mut peer = sim.pi_peer("n", "pi", node).await; peer.write(rel, b"header\ndelete me\n"); peer.write(keep, b"keep\n"); peer.engine.persist_state(&state_path); @@ -342,7 +353,7 @@ async fn deletion_while_daemon_down_is_not_reimported() { let mut node = sim.node("n").await; node.open_namespace(ns).await.unwrap(); - let mut peer = sim.pi_peer("n", "pi", node); + let mut peer = sim.pi_peer("n", "pi", node).await; peer.engine.persist_state(&state_path); peer.tick().await; @@ -353,17 +364,17 @@ async fn deletion_while_daemon_down_is_not_reimported() { #[tokio::test] async fn divergent_sessions_merge_and_converge() { - let sim = Sim::new("merge"); + let sim = Sim::new("merge").await; let rel = "--proj--/2026-01-01T00-00-00-000Z_019eccccdddd71acbe20merge0000001.jsonl"; let mut node_a = sim.node("a").await; node_a.create_namespace().await.unwrap(); let ticket = node_a.share().await.unwrap(); - let peer_a = sim.pi_peer("a", "pi", node_a); + let peer_a = sim.pi_peer("a", "pi", node_a).await; let mut node_b = sim.node("b").await; node_b.join(ticket).await.unwrap(); - let peer_b = sim.pi_peer("b", "pi", node_b); + let peer_b = sim.pi_peer("b", "pi", node_b).await; // each machine has its own divergent version of the same session peer_a.write(rel, b"header\ncommon\nonly-on-a\n"); @@ -391,21 +402,21 @@ async fn divergent_sessions_merge_and_converge() { #[tokio::test] async fn divergent_writes_are_detected_as_conflict() { - let sim = Sim::new("conflict"); + let sim = Sim::new("conflict").await; let rel = "--proj--/2026-01-01T00-00-00-000Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; // node A publishes its version of the session let mut node_a = sim.node("a").await; node_a.create_namespace().await.unwrap(); let ticket = node_a.share().await.unwrap(); - let mut peer_a = sim.pi_peer("a", "pi", node_a); + let mut peer_a = sim.pi_peer("a", "pi", node_a).await; peer_a.write(rel, b"version from A\n"); peer_a.tick().await; // node B joins, then publishes its OWN divergent version of the same session let mut node_b = sim.node("b").await; node_b.join(ticket).await.unwrap(); - let mut peer_b = sim.pi_peer("b", "pi", node_b); + let mut peer_b = sim.pi_peer("b", "pi", node_b).await; peer_b.write(rel, b"different version from B\n"); peer_b.tick().await; @@ -431,7 +442,7 @@ async fn divergent_writes_are_detected_as_conflict() { #[tokio::test] async fn pi_and_omp_sessions_sync_side_by_side() { - let sim = Sim::new("multiagent"); + let sim = Sim::new("multiagent").await; // node A: one pi session and one omp session in their own roots let pi_root_a = sim.base.join("a/pi-sessions"); @@ -453,7 +464,7 @@ async fn pi_and_omp_sessions_sync_side_by_side() { Box::new(PiAdapter::new("pi", &pi_root_a)) as Box, Box::new(PiAdapter::new("omp", &omp_root_a)), ], - sim.identity(), + sim.identity().await, node_a, ); peer_a.tick().await; @@ -471,7 +482,7 @@ async fn pi_and_omp_sessions_sync_side_by_side() { Box::new(PiAdapter::new("pi", &pi_root_b)) as Box, Box::new(PiAdapter::new("omp", &omp_root_b)), ], - sim.identity(), + sim.identity().await, node_b, ); @@ -487,7 +498,7 @@ async fn pi_and_omp_sessions_sync_side_by_side() { #[tokio::test] async fn omp_blob_store_syncs_binary_blobs() { - let sim = Sim::new("blobstore"); + let sim = Sim::new("blobstore").await; // node A: one blob as omp writes it — bare hash plus a `.png` alias, // identical binary (non-UTF8) content. @@ -504,7 +515,7 @@ async fn omp_blob_store_syncs_binary_blobs() { let mut peer_a = sim.peer( "a", vec![Box::new(BlobStoreAdapter::new("omp-blobs", &blob_root_a)) as Box], - sim.identity(), + sim.identity().await, node_a, ); peer_a.tick().await; @@ -517,7 +528,7 @@ async fn omp_blob_store_syncs_binary_blobs() { let mut peer_b = sim.peer( "b", vec![Box::new(BlobStoreAdapter::new("omp-blobs", &blob_root_b)) as Box], - sim.identity(), + sim.identity().await, node_b, ); @@ -533,7 +544,7 @@ async fn omp_blob_store_syncs_binary_blobs() { #[tokio::test] async fn missed_content_download_is_fetched_on_write() { - let sim = Sim::new("fetch"); + let sim = Sim::new("fetch").await; let ns_secret = ssync_net::generate_key_bytes(); let rel = "--proj--/2026-01-01T00-00-00-000Z_019efeeed0001eee71acbe20fetch0001.jsonl"; @@ -551,10 +562,10 @@ async fn missed_content_download_is_fetched_on_write() { node_a.sync_with(vec![addr_b]).await.unwrap(); node_b.sync_with(vec![addr_a]).await.unwrap(); - let mut peer_a = sim.pi_peer("a", "pi", node_a); + let mut peer_a = sim.pi_peer("a", "pi", node_a).await; peer_a.write(rel, contents); peer_a.tick().await; - let mut peer_b = sim.pi_peer("b", "pi", node_b); + let mut peer_b = sim.pi_peer("b", "pi", node_b).await; // the file can only materialize via the explicit peer fetch let dest = peer_b.path(rel); @@ -564,7 +575,7 @@ async fn missed_content_download_is_fetched_on_write() { #[tokio::test(flavor = "multi_thread")] async fn sync_recovers_when_peer_comes_up_late() { - let sim = Sim::new("resync"); + let sim = Sim::new("resync").await; let ns_secret = ssync_net::generate_key_bytes(); let rel = "--proj--/2026-01-01T00-00-00-000Z_019eresync001eee71acbe20resync001.jsonl"; @@ -578,7 +589,7 @@ async fn sync_recovers_when_peer_comes_up_late() { // dialing a peer that is down. B never learns about A on its own. node_a.sync_with(vec![addr_b]).await.unwrap(); - let mut peer_a = sim.pi_peer("a", "pi", node_a); + let mut peer_a = sim.pi_peer("a", "pi", node_a).await; peer_a.write(rel, contents); peer_a.engine.set_resync_interval(Duration::from_secs(2)); peer_a.run(); @@ -594,7 +605,7 @@ async fn sync_recovers_when_peer_comes_up_late() { .sync_with(vec![ssync_net::iroh::EndpointAddr::from(bogus)]) .await .unwrap(); - let peer_b = sim.pi_peer("b", "pi", node_b); + let peer_b = sim.pi_peer("b", "pi", node_b).await; let root_b = peer_b.run(); let dest = root_b.join(rel); @@ -610,7 +621,7 @@ async fn ticket_issuer_learns_peers_and_recovers_missed_content() { // The ticket issuer starts with an empty peer list (`join` only records // peers on the joining side), so a missed content download on the issuer // is unrecoverable unless it learns the joiner from live sync events. - let sim = Sim::new("learn-peer"); + let sim = Sim::new("learn-peer").await; let rel = "--proj--/2026-01-01T00-00-00-000Z_019efeeed0001eee71acbe20learn0001.jsonl"; let contents = b"content the issuer failed to auto-download\n"; @@ -624,7 +635,7 @@ async fn ticket_issuer_learns_peers_and_recovers_missed_content() { let ticket = node_a.share().await.unwrap(); node_b.join(ticket).await.unwrap(); - let peer_b = sim.pi_peer("b", "pi", node_b); + let peer_b = sim.pi_peer("b", "pi", node_b).await; peer_b.write(rel, contents); peer_b.run(); @@ -642,7 +653,7 @@ async fn ticket_issuer_learns_peers_and_recovers_missed_content() { } assert!(synced, "joiner's entry never reached the issuer's index"); - let peer_a = sim.pi_peer("a", "pi", node_a); + let peer_a = sim.pi_peer("a", "pi", node_a).await; let root_a = peer_a.run(); let dest = root_a.join(rel); @@ -655,7 +666,7 @@ async fn ticket_issuer_learns_peers_and_recovers_missed_content() { /// there when a peer publishes it. Non-excluded traffic flows normally. #[tokio::test] async fn excluded_projects_neither_publish_nor_materialize() { - let sim = Sim::new("exclude"); + let sim = Sim::new("exclude").await; let rel_normal = "--home-x-demo--/2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; @@ -667,7 +678,7 @@ async fn excluded_projects_neither_publish_nor_materialize() { let mut node_a = sim.node("a").await; node_a.create_namespace().await.unwrap(); let ticket = node_a.share().await.unwrap(); - let mut peer_a = sim.pi_peer("a", "pi", node_a); + let mut peer_a = sim.pi_peer("a", "pi", node_a).await; for rel in [rel_normal, rel_secret] { peer_a.write(rel, &contents); } @@ -679,7 +690,7 @@ async fn excluded_projects_neither_publish_nor_materialize() { // --- node B: no excludes --- let mut node_b = sim.node("b").await; node_b.join(ticket).await.unwrap(); - let mut peer_b = sim.pi_peer("b", "pi", node_b); + let mut peer_b = sim.pi_peer("b", "pi", node_b).await; // the normal session reaches B ... let dest_normal = peer_b.path(rel_normal); @@ -721,7 +732,7 @@ async fn excluded_projects_neither_publish_nor_materialize() { /// guard propagates deletion to every peer. #[tokio::test] async fn removing_an_agent_freezes_it_instead_of_tombstoning() { - let sim = Sim::new("agent-drop"); + let sim = Sim::new("agent-drop").await; let two_adapters = |root_pi: &Path, root_omp: &Path| -> Vec> { vec![ Box::new(PiAdapter::new("pi", root_pi)), @@ -744,7 +755,7 @@ async fn removing_an_agent_freezes_it_instead_of_tombstoning() { let mut peer_a = sim.peer( "a", two_adapters(&root_pi_a, &root_omp_a), - sim.identity(), + sim.identity().await, node_a, ); peer_a.persist(); @@ -760,7 +771,7 @@ async fn removing_an_agent_freezes_it_instead_of_tombstoning() { let mut peer_b = sim.peer( "b", two_adapters(&root_pi_b, &root_omp_b), - sim.identity(), + sim.identity().await, node_b, ); let dest = root_omp_b.join(rel); @@ -775,7 +786,7 @@ async fn removing_an_agent_freezes_it_instead_of_tombstoning() { let mut peer_a2 = sim.peer( "a", vec![Box::new(PiAdapter::new("pi", &root_pi_a)) as Box], - sim.identity(), + sim.identity().await, node_a2, ); peer_a2.persist(); @@ -806,7 +817,7 @@ async fn removing_an_agent_freezes_it_instead_of_tombstoning() { /// hard case, omp's home-relative wire keys derived via `canonical_home`. #[tokio::test] async fn path_mapped_machines_converge() { - let sim = Sim::new("pathmap"); + let sim = Sim::new("pathmap").await; let canonical_home = "/canon-home"; let canonical_cwd = "/canon-home/Projects/x"; @@ -820,7 +831,7 @@ async fn path_mapped_machines_converge() { let mut node_a = sim.node("a").await; node_a.create_namespace().await.unwrap(); let ticket = node_a.share().await.unwrap(); - let mut peer_a = sim.pi_peer("a", "omp", node_a); + let mut peer_a = sim.pi_peer("a", "omp", node_a).await; peer_a.write(rel_a, &canonical_bytes); peer_a.tick().await; let src_a = peer_a.path(rel_a); @@ -838,7 +849,7 @@ async fn path_mapped_machines_converge() { let mut node_b = sim.node("b").await; node_b.join(ticket).await.unwrap(); - let mut peer_b = sim.pi_peer("b", "omp", node_b); + let mut peer_b = sim.pi_peer("b", "omp", node_b).await; peer_b.engine.set_path_map(map, Some(canonical_home.into())); let root_b = peer_b.root.clone(); @@ -936,7 +947,7 @@ async fn path_mapped_machines_converge() { /// is active for another agent. #[tokio::test] async fn path_map_leaves_cwdless_adapters_alone() { - let sim = Sim::new("pathmap-blobs"); + let sim = Sim::new("pathmap-blobs").await; let adapters = |root_pi: &Path, root_blobs: &Path| -> Vec> { vec![ Box::new(PiAdapter::new("pi", root_pi)), @@ -958,7 +969,7 @@ async fn path_map_leaves_cwdless_adapters_alone() { let mut peer_a = sim.peer( "a", adapters(&root_pi_a, &root_blobs_a), - sim.identity(), + sim.identity().await, node_a, ); peer_a.tick().await; @@ -972,7 +983,7 @@ async fn path_map_leaves_cwdless_adapters_alone() { let mut peer_b = sim.peer( "b", adapters(&root_pi_b, &root_blobs_b), - sim.identity(), + sim.identity().await, node_b, ); peer_b.engine.set_path_map( @@ -1002,7 +1013,7 @@ async fn path_map_leaves_cwdless_adapters_alone() { /// canonical_home) — peers must keep their copies. #[tokio::test] async fn unresolvable_mapping_freezes_instead_of_tombstoning() { - let sim = Sim::new("pathmap-freeze"); + let sim = Sim::new("pathmap-freeze").await; let rel = "-Projects-x/2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; // header cwd points INSIDE the prefix B will later map @@ -1013,7 +1024,7 @@ async fn unresolvable_mapping_freezes_instead_of_tombstoning() { let mut node_b = sim.node_with_key("b", key_b.clone()).await; let ns = node_b.create_namespace().await.unwrap(); let ticket = node_b.share().await.unwrap(); - let mut peer_b = sim.pi_peer("b", "omp", node_b); + let mut peer_b = sim.pi_peer("b", "omp", node_b).await; peer_b.write(rel, &bytes); let src_b = peer_b.path(rel); peer_b.persist(); @@ -1023,7 +1034,7 @@ async fn unresolvable_mapping_freezes_instead_of_tombstoning() { let mut node_a = sim.node("a").await; node_a.join(ticket).await.unwrap(); let a_addr = node_a.endpoint_addr(); - let mut peer_a = sim.pi_peer("a", "omp", node_a); + let mut peer_a = sim.pi_peer("a", "omp", node_a).await; let dest_a = peer_a.path(rel); let ok = converge(&mut [&mut peer_a], || file_eq(&dest_a, &bytes)).await; assert!(ok, "session did not sync to A"); @@ -1034,7 +1045,7 @@ async fn unresolvable_mapping_freezes_instead_of_tombstoning() { let mut node_b2 = sim.node_with_key("b", key_b).await; node_b2.open_namespace(ns).await.unwrap(); node_b2.sync_with(vec![a_addr]).await.unwrap(); - let mut peer_b2 = sim.pi_peer("b", "omp", node_b2); + let mut peer_b2 = sim.pi_peer("b", "omp", node_b2).await; peer_b2.engine.set_path_map( ssync_core::PathMap::new(vec![("/data".into(), "/other-canon".into())]).unwrap(), None, // omp cannot encode home-relative canonicals without this diff --git a/crates/ssync-core/tests/recipient_rotation.rs b/crates/ssync-core/tests/recipient_rotation.rs index 1ef131b..54516d8 100644 --- a/crates/ssync-core/tests/recipient_rotation.rs +++ b/crates/ssync-core/tests/recipient_rotation.rs @@ -24,9 +24,9 @@ async fn winner_of(node: &Node, key: &str) -> Option { #[tokio::test] async fn recipient_change_republishes_unchanged_sessions() { - let sim = Sim::new("rotation-recipients"); - let id_a = AgeIdentity::generate().unwrap(); - let id_b = AgeIdentity::generate().unwrap(); + let sim = Sim::new("rotation-recipients").await; + let id_a = AgeIdentity::generate().await.unwrap(); + let id_b = AgeIdentity::generate().await.unwrap(); let rel = "--home-simon-Projects-demo--/2026-05-23T06-55-21-771Z_019e539d-f6ab-71ac-be20-d3ae2b23ea4a.jsonl"; let contents = b"{\"type\":\"session\",\"version\":3}\n{\"msg\":\"unchanged\"}\n"; @@ -42,7 +42,9 @@ async fn recipient_change_republishes_unchanged_sessions() { observer.join(ticket).await.unwrap(); // run 1: encrypts to {A} only, persists state (and with it the set). - let ident1 = AgeIdentity::from_secret_string(&id_a.to_secret_string()).unwrap(); + let ident1 = AgeIdentity::from_secret_string(&id_a.to_secret_string()) + .await + .unwrap(); let mut peer = sim.pi_peer_as("a", "pi", ident1, node_a); peer.write(rel, contents); peer.persist(); @@ -68,7 +70,9 @@ async fn recipient_change_republishes_unchanged_sessions() { .sync_with(vec![observer.endpoint_addr()]) .await .unwrap(); - let mut ident2 = AgeIdentity::from_secret_string(&id_a.to_secret_string()).unwrap(); + let mut ident2 = AgeIdentity::from_secret_string(&id_a.to_secret_string()) + .await + .unwrap(); ident2.add_recipients([id_b.recipient_string()]); let mut peer2 = sim.pi_peer_as("a", "pi", ident2, node_a2); peer2.persist(); @@ -91,6 +95,7 @@ async fn recipient_change_republishes_unchanged_sessions() { for _ in 0..60 { if let Ok(ciphertext) = observer.get_blob(h2).await { plain = AgeIdentity::from_secret_string(&id_b.to_secret_string()) + .await .unwrap() .decrypt(&ciphertext) .await @@ -118,7 +123,7 @@ async fn recipient_change_republishes_unchanged_sessions() { #[tokio::test] async fn namespace_rotation_drops_stale_replica() { - let sim = Sim::new("rotation-namespace"); + let sim = Sim::new("rotation-namespace").await; let mut node = sim.node("n").await; let old = node.create_namespace().await.unwrap(); node.publish("pi/x", b"old ciphertext".to_vec()) diff --git a/crates/ssync-crypto/src/lib.rs b/crates/ssync-crypto/src/lib.rs index 64bc473..72b7ca8 100644 --- a/crates/ssync-crypto/src/lib.rs +++ b/crates/ssync-crypto/src/lib.rs @@ -4,10 +4,10 @@ //! X25519-only Rust `age` crate backend stays disabled behind `rust-age`. use std::ffi::OsStr; -use std::io::Write; -use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; -use std::path::PathBuf; -use std::process::{Command as StdCommand, Stdio}; +use std::io::{self, Write}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; +use std::process::Stdio; use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; @@ -31,9 +31,12 @@ pub struct AgeIdentity { impl AgeIdentity { /// Generate a fresh post-quantum hybrid identity (`age-keygen -pq`). - pub fn generate() -> Result { + pub async fn generate() -> Result { let commands = AgeCommands::resolve()?; - let out = commands.keygen(["-pq"]).context("running age-keygen -pq")?; + let out = commands + .keygen(&[OsStr::new("-pq")]) + .await + .context("running age-keygen -pq")?; let text = String::from_utf8(out).context("age-keygen output not utf-8")?; let mut secret = None; let mut recipient = None; @@ -48,7 +51,7 @@ impl AgeIdentity { let secret = secret.ok_or_else(|| anyhow!("age-keygen produced no secret key"))?; let recipient = match recipient { Some(r) => r, - None => recipient_of(&commands, &secret)?, + None => recipient_of(&commands, &secret).await?, }; Ok(Self { secret, @@ -60,18 +63,18 @@ impl AgeIdentity { /// Build from an age identity: either a bare `AGE-SECRET-KEY[-PQ]-1…` line or /// a full `age-keygen` file (comment lines are ignored). - pub fn from_secret_string(s: &str) -> Result { - Self::from_secret_string_with_commands(s, AgeCommands::resolve()?) + pub async fn from_secret_string(s: &str) -> Result { + Self::from_secret_string_with_commands(s, AgeCommands::resolve()?).await } - fn from_secret_string_with_commands(s: &str, commands: AgeCommands) -> Result { + async fn from_secret_string_with_commands(s: &str, commands: AgeCommands) -> Result { let secret = s .lines() .map(str::trim) .find(|l| l.starts_with("AGE-SECRET-KEY-")) .ok_or_else(|| anyhow!("no age secret key found"))? .to_string(); - let recipient = recipient_of(&commands, &secret)?; + let recipient = recipient_of(&commands, &secret).await?; Ok(Self { secret, recipient, @@ -113,102 +116,113 @@ impl AgeIdentity { /// Encrypt `plaintext` to this identity's recipient plus any added peer /// recipients (binary age output). pub async fn encrypt(&self, plaintext: &[u8]) -> Result> { - let mut command = self.commands.age(); - command.args(["-e", "-r", &self.recipient]); - for recipient in &self.extra_recipients { - command.args(["-r", recipient]); - } - run(&mut command, plaintext, self.commands.inactivity_timeout) - .await - .context("age encrypt") + run( + &self.commands.age, + |command| { + command.args(["-e", "-r", &self.recipient]); + for recipient in &self.extra_recipients { + command.args(["-r", recipient]); + } + }, + plaintext, + self.commands.inactivity_timeout, + ) + .await + .context("age encrypt") } /// Decrypt age `ciphertext` with this identity. pub async fn decrypt(&self, ciphertext: &[u8]) -> Result> { let key = SecretFile::new(&self.secret)?; - let mut command = self.commands.age(); - command.arg("-d").arg("-i").arg(&key.path); - run(&mut command, ciphertext, self.commands.inactivity_timeout) - .await - .context("age decrypt (wrong identity?)") + run( + &self.commands.age, + |command| { + command.arg("-d").arg("-i").arg(&key.path); + }, + ciphertext, + self.commands.inactivity_timeout, + ) + .await + .context("age decrypt (wrong identity?)") } } const INACTIVITY_TIMEOUT: Duration = Duration::from_secs(30); const IO_CHUNK: usize = 64 * 1024; +const ENOEXEC: i32 = 8; struct AgeCommands { - age: PathBuf, - age_keygen: PathBuf, + age: Executable, + age_keygen: Executable, inactivity_timeout: Duration, } impl AgeCommands { fn resolve() -> Result { - Ok(Self::new( - resolve_executable("age")?, - resolve_executable("age-keygen")?, - INACTIVITY_TIMEOUT, - )) + Ok(Self { + age: Executable::resolve("age")?, + age_keygen: Executable::resolve("age-keygen")?, + inactivity_timeout: INACTIVITY_TIMEOUT, + }) } - fn new(age: PathBuf, age_keygen: PathBuf, inactivity_timeout: Duration) -> Self { - Self { - age, - age_keygen, - inactivity_timeout, - } + async fn keygen(&self, args: &[&OsStr]) -> Result> { + run( + &self.age_keygen, + |command| { + command.args(args); + }, + &[], + self.inactivity_timeout, + ) + .await + .context("running age-keygen") } +} - fn age(&self) -> Command { - let mut command = Command::new(&self.age); - command.env_clear(); - command - } - - fn keygen(&self, args: I) -> Result> - where - I: IntoIterator, - S: AsRef, - { - let output = StdCommand::new(&self.age_keygen) - .env_clear() - .args(args) - .stdin(Stdio::null()) - .output() - .with_context(|| format!("spawning {}", self.age_keygen.display()))?; - if !output.status.success() { - bail!("{}", String::from_utf8_lossy(&output.stderr).trim()); - } - Ok(output.stdout) - } +struct Executable { + name: String, + candidates: Vec, } -fn resolve_executable(name: &str) -> Result { - let path = std::env::var_os("PATH").ok_or_else(|| anyhow!("PATH is not set"))?; - for dir in std::env::split_paths(&path) { - let candidate = dir.join(name); - let candidate = if candidate.is_absolute() { - candidate - } else { - std::env::current_dir() - .context("resolving executable path")? - .join(candidate) - }; - let Ok(metadata) = candidate.metadata() else { - continue; - }; - if metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 { - return Ok(candidate); +impl Executable { + fn resolve(name: &str) -> Result { + let path = std::env::var_os("PATH").ok_or_else(|| anyhow!("PATH is not set"))?; + let current_dir = std::env::current_dir().context("resolving executable path")?; + Self::resolve_on_path(name, &path, ¤t_dir) + } + + fn resolve_on_path(name: &str, path: &OsStr, current_dir: &Path) -> Result { + let candidates = std::env::split_paths(path) + .map(|dir| { + let candidate = dir.join(name); + if candidate.is_absolute() { + candidate + } else { + current_dir.join(candidate) + } + }) + .filter(|candidate| { + candidate + .metadata() + .is_ok_and(|metadata| metadata.is_file()) + }) + .collect::>(); + if candidates.is_empty() { + bail!("{name} not found on PATH"); } + Ok(Self { + name: name.to_string(), + candidates, + }) } - bail!("{name} not found on PATH") } -fn recipient_of(commands: &AgeCommands, secret: &str) -> Result { +async fn recipient_of(commands: &AgeCommands, secret: &str) -> Result { let key = SecretFile::new(secret)?; let out = commands - .keygen(["-y".as_ref(), key.path.as_os_str()]) + .keygen(&["-y".as_ref(), key.path.as_os_str()]) + .await .context("age-keygen -y")?; let recipient = String::from_utf8(out) .context("age-keygen -y output not utf-8")? @@ -225,16 +239,52 @@ async fn terminate(child: &mut Child) { let _ = child.wait().await; } -/// Run `command` with `input` on stdin. The timeout resets on every I/O event. -async fn run(command: &mut Command, input: &[u8], inactivity_timeout: Duration) -> Result> { - let program = command.as_std().get_program().to_owned(); - let mut child = command - .kill_on_drop(true) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .with_context(|| format!("spawning {program:?}"))?; +/// Run `executable` with `input` on stdin. The timeout resets on every I/O event. +async fn run( + executable: &Executable, + configure: impl Fn(&mut Command), + input: &[u8], + inactivity_timeout: Duration, +) -> Result> { + let mut child = None; + let mut last_error = None; + for candidate in &executable.candidates { + let mut command = Command::new(candidate); + command.env_clear(); + configure(&mut command); + match command + .kill_on_drop(true) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(spawned) => { + child = Some(spawned); + break; + } + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied + ) || error.raw_os_error() == Some(ENOEXEC) => + { + last_error = Some((candidate, error)); + } + Err(error) => { + return Err(error).with_context(|| format!("spawning {}", candidate.display())); + } + } + } + let mut child = match child { + Some(child) => child, + None => { + let (candidate, error) = last_error.expect("executable has candidates"); + return Err(error).with_context(|| { + format!("spawning {} ({})", executable.name, candidate.display()) + }); + } + }; let mut stdin = child.stdin.take(); let mut stdout = child.stdout.take().expect("piped stdout"); let mut stderr = child.stderr.take().expect("piped stderr"); @@ -396,41 +446,115 @@ mod tests { fn script(dir: &Path, name: &str, body: &str) -> PathBuf { let path = dir.join(name); - std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + let mut file = std::fs::File::create(&path).unwrap(); + file.write_all(format!("#!/bin/sh\n{body}\n").as_bytes()) + .unwrap(); + file.sync_all().unwrap(); + drop(file); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); path } - fn identity_with_commands(age: PathBuf, age_keygen: PathBuf, timeout: Duration) -> AgeIdentity { + fn commands(age: PathBuf, age_keygen: PathBuf, inactivity_timeout: Duration) -> AgeCommands { + AgeCommands { + age: Executable { + name: "age".to_string(), + candidates: vec![age], + }, + age_keygen: Executable { + name: "age-keygen".to_string(), + candidates: vec![age_keygen], + }, + inactivity_timeout, + } + } + + fn system_executable(name: &str) -> PathBuf { + Executable::resolve(name) + .unwrap() + .candidates + .into_iter() + .next() + .unwrap() + } + + async fn identity_with_commands( + age: PathBuf, + age_keygen: PathBuf, + timeout: Duration, + ) -> AgeIdentity { AgeIdentity::from_secret_string_with_commands( "AGE-SECRET-KEY-1-TEST", - AgeCommands::new(age, age_keygen, timeout), + commands(age, age_keygen, timeout), ) + .await .unwrap() } - #[test] - fn injected_keygen_path_is_used() { + #[tokio::test] + async fn injected_keygen_path_is_used() { let dir = scratch("injected-keygen"); let age = script(&dir, "age", "exit 1"); let age_keygen = script(&dir, "age-keygen", "printf 'age1injected\\n'"); - let id = identity_with_commands(age, age_keygen, Duration::from_secs(1)); + let id = identity_with_commands(age, age_keygen, Duration::from_secs(1)).await; assert_eq!(id.recipient_string(), "age1injected"); std::fs::remove_dir_all(dir).unwrap(); } - #[test] - fn resolved_commands_are_absolute() { - let commands = AgeCommands::resolve().unwrap(); - assert!(commands.age.is_absolute()); - assert!(commands.age_keygen.is_absolute()); + #[tokio::test] + async fn unusable_path_entry_is_skipped() { + let dir = scratch("path-permissions"); + let first = dir.join("first"); + let second = dir.join("second"); + std::fs::create_dir_all(&first).unwrap(); + std::fs::create_dir_all(&second).unwrap(); + let unusable = script(&first, "age", "printf wrong"); + std::fs::set_permissions(&unusable, std::fs::Permissions::from_mode(0o010)).unwrap(); + std::os::unix::fs::symlink(system_executable("sh"), second.join("age")).unwrap(); + let path = std::env::join_paths([first, second]).unwrap(); + + let executable = Executable::resolve_on_path("age", &path, &dir).unwrap(); + let output = run( + &executable, + |command| { + command.args(["-c", "printf usable"]); + }, + &[], + Duration::from_secs(1), + ) + .await + .unwrap(); + assert_eq!(output, b"usable"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[tokio::test] + async fn relative_path_entry_remains_runnable() { + let dir = scratch("relative-path"); + let bin = dir.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::os::unix::fs::symlink(system_executable("sh"), bin.join("age")).unwrap(); + + let executable = Executable::resolve_on_path("age", OsStr::new("bin"), &dir).unwrap(); + let output = run( + &executable, + |command| { + command.args(["-c", "printf pinned"]); + }, + &[], + Duration::from_secs(1), + ) + .await + .unwrap(); + assert_eq!(output, b"pinned"); + std::fs::remove_dir_all(dir).unwrap(); } #[tokio::test] async fn age_child_receives_empty_environment() { let dir = scratch("empty-environment"); - let cat = resolve_executable("cat").unwrap(); + let cat = system_executable("cat"); let age = script( &dir, "age", @@ -440,17 +564,50 @@ mod tests { ), ); let age_keygen = script(&dir, "age-keygen", "printf 'age1injected\\n'"); - let id = identity_with_commands(age, age_keygen, Duration::from_secs(1)); + let id = identity_with_commands(age, age_keygen, Duration::from_secs(1)).await; assert_eq!(id.encrypt(b"plaintext").await.unwrap(), b"clean"); std::fs::remove_dir_all(dir).unwrap(); } + #[tokio::test] + async fn stalled_keygen_is_bounded() { + let dir = scratch("keygen-inactivity"); + let pid_file = dir.join("pid"); + let sleep = system_executable("sleep"); + let age = script(&dir, "age", "exit 1"); + let age_keygen = script( + &dir, + "age-keygen", + &format!( + "printf '%s' $$ > '{}'\nexec '{}' 60", + pid_file.display(), + sleep.display() + ), + ); + let commands = commands(age, age_keygen, Duration::from_millis(500)); + + let started = Instant::now(); + let Err(error) = + AgeIdentity::from_secret_string_with_commands("AGE-SECRET-KEY-1-TEST", commands).await + else { + panic!("stalled keygen must fail"); + }; + assert!( + format!("{error:#}").contains("inactive"), + "unexpected error: {error:#}" + ); + assert!(started.elapsed() < Duration::from_secs(2)); + let pid = std::fs::read_to_string(&pid_file).unwrap(); + assert!(!Path::new("/proc").join(pid.trim()).exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + #[tokio::test] async fn inactivity_timeout_kills_and_reaps_child() { let dir = scratch("inactivity"); let pid_file = dir.join("pid"); - let sleep = resolve_executable("sleep").unwrap(); + let sleep = system_executable("sleep"); let age = script( &dir, "age", @@ -461,7 +618,7 @@ mod tests { ), ); let age_keygen = script(&dir, "age-keygen", "printf 'age1injected\\n'"); - let id = identity_with_commands(age, age_keygen, Duration::from_millis(500)); + let id = identity_with_commands(age, age_keygen, Duration::from_millis(500)).await; let started = Instant::now(); let error = id.encrypt(b"plaintext").await.unwrap_err(); @@ -480,11 +637,55 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + #[tokio::test] + async fn cancelled_operation_reaps_child() { + let dir = scratch("cancelled"); + let bin = dir.join("bin"); + let pid_file = dir.join("pid"); + std::fs::create_dir_all(&bin).unwrap(); + std::os::unix::fs::symlink(system_executable("sh"), bin.join("age")).unwrap(); + let executable = Executable::resolve_on_path("age", OsStr::new("bin"), &dir).unwrap(); + let child_command = format!( + "printf '%s' $$ > '{}'; exec '{}' 60", + pid_file.display(), + system_executable("sleep").display() + ); + let operation = tokio::spawn(async move { + run( + &executable, + |command| { + command.args(["-c", &child_command]); + }, + &[], + Duration::from_secs(30), + ) + .await + }); + for _ in 0..100 { + if pid_file.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let pid = std::fs::read_to_string(&pid_file).unwrap(); + + operation.abort(); + assert!(operation.await.unwrap_err().is_cancelled()); + for _ in 0..100 { + if !Path::new("/proc").join(pid.trim()).exists() { + std::fs::remove_dir_all(dir).unwrap(); + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("cancelled child was not reaped"); + } + #[tokio::test] async fn decrypt_identity_file_lives_for_one_operation() { let dir = scratch("identity-lifetime"); let report = dir.join("identity-path"); - let cat = resolve_executable("cat").unwrap(); + let cat = system_executable("cat"); let age = script( &dir, "age", @@ -495,7 +696,7 @@ mod tests { ), ); let age_keygen = script(&dir, "age-keygen", "printf 'age1injected\\n'"); - let id = identity_with_commands(age, age_keygen, Duration::from_secs(1)); + let id = identity_with_commands(age, age_keygen, Duration::from_secs(1)).await; assert_eq!(id.decrypt(b"ciphertext").await.unwrap(), b"plain"); let identity_path = std::fs::read_to_string(report).unwrap(); @@ -505,7 +706,7 @@ mod tests { #[tokio::test] async fn round_trip_is_byte_identical() { - let id = AgeIdentity::generate().unwrap(); + let id = AgeIdentity::generate().await.unwrap(); assert!( id.recipient_string().starts_with("age1pq1"), "expected PQ recipient" @@ -517,39 +718,43 @@ mod tests { assert_eq!(&pt[..], &plaintext[..]); } - #[test] - fn secret_string_round_trips() { - let id = AgeIdentity::generate().unwrap(); - let id2 = AgeIdentity::from_secret_string(&id.to_secret_string()).unwrap(); + #[tokio::test] + async fn secret_string_round_trips() { + let id = AgeIdentity::generate().await.unwrap(); + let id2 = AgeIdentity::from_secret_string(&id.to_secret_string()) + .await + .unwrap(); assert_eq!(id.recipient_string(), id2.recipient_string()); } - #[test] - fn parses_full_age_keygen_file() { - let id = AgeIdentity::generate().unwrap(); + #[tokio::test] + async fn parses_full_age_keygen_file() { + let id = AgeIdentity::generate().await.unwrap(); let file = format!( "# created: 2026\n# public key: {}\n{}\n", id.recipient_string(), id.to_secret_string() ); - let id2 = AgeIdentity::from_secret_string(&file).unwrap(); + let id2 = AgeIdentity::from_secret_string(&file).await.unwrap(); assert_eq!(id.recipient_string(), id2.recipient_string()); } #[tokio::test] async fn wrong_identity_cannot_decrypt() { - let a = AgeIdentity::generate().unwrap(); - let b = AgeIdentity::generate().unwrap(); + let a = AgeIdentity::generate().await.unwrap(); + let b = AgeIdentity::generate().await.unwrap(); let ct = a.encrypt(b"hello").await.unwrap(); assert!(b.decrypt(&ct).await.is_err()); } #[tokio::test] async fn extra_recipients_can_decrypt_and_self_stays_included() { - let a = AgeIdentity::generate().unwrap(); - let b = AgeIdentity::generate().unwrap(); - let c = AgeIdentity::generate().unwrap(); - let mut sender = AgeIdentity::from_secret_string(&a.to_secret_string()).unwrap(); + let a = AgeIdentity::generate().await.unwrap(); + let b = AgeIdentity::generate().await.unwrap(); + let c = AgeIdentity::generate().await.unwrap(); + let mut sender = AgeIdentity::from_secret_string(&a.to_secret_string()) + .await + .unwrap(); // duplicate of self plus b: dedup must not break encryption sender.add_recipients([a.recipient_string(), b.recipient_string()]); let ct = sender.encrypt(b"shared session").await.unwrap(); diff --git a/crates/ssync/src/cluster.rs b/crates/ssync/src/cluster.rs index 49877d7..b8726d4 100644 --- a/crates/ssync/src/cluster.rs +++ b/crates/ssync/src/cluster.rs @@ -63,7 +63,7 @@ pub async fn cmd_init(config_path: &Path, path: Option) -> Result<()> { } ensure_no_recipients(&config)?; - let identity = load_or_generate_identity(&config)?; + let identity = load_or_generate_identity(&config).await?; let node_key_path = config.node_key_file(); let secret = ssync_net::load_or_create_secret_key(&node_key_path).await?; let node_id = secret.public().to_string(); @@ -311,7 +311,7 @@ mod tests { std::env::temp_dir().join(format!("ssync-cluster-{}-init-text", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let config_path = dir.join("config.toml"); - let identity = ssync_crypto::AgeIdentity::generate().unwrap(); + let identity = ssync_crypto::AgeIdentity::generate().await.unwrap(); write_secret(&dir.join("age.key"), &identity.to_secret_string()).unwrap(); let original = format!( "# hand-written config, keep me\nage_identity_path = \"{}\"\ndata_dir = \"{}\"\n# agents to sync\n[[agents]]\nagent = \"pi\"\nsession_dir = \"{}\"\n", diff --git a/crates/ssync/src/main.rs b/crates/ssync/src/main.rs index b5f335f..f093a62 100644 --- a/crates/ssync/src/main.rs +++ b/crates/ssync/src/main.rs @@ -363,7 +363,7 @@ fn cmd_cleanup( async fn cmd_init(config_path: &Path) -> Result<()> { let (config, _pre_existed) = load_or_bootstrap_config(config_path)?; let already_had_identity = config.age_identity_path.exists(); - let id = load_or_generate_identity(&config)?; + let id = load_or_generate_identity(&config).await?; println!("age recipient: {}", id.recipient_string()); if !already_had_identity { println!( @@ -395,7 +395,7 @@ async fn cmd_daemon(config_path: &Path) -> Result<()> { // own key; otherwise the key is shared and a second standalone machine // must be given this same key (clan.vars handles either mode). if !config.age_identity_path.exists() { - let id = AgeIdentity::generate()?; + let id = AgeIdentity::generate().await?; write_secret(&config.age_identity_path, &id.to_secret_string())?; if config.cluster_path.is_none() { if config.recipients.is_empty() { @@ -423,7 +423,7 @@ async fn cmd_daemon(config_path: &Path) -> Result<()> { ), None => None, }; - let mut identity = load_identity(&config.age_identity_path)?; + let mut identity = load_identity(&config.age_identity_path).await?; match &cluster { Some(c) => identity.add_recipients(c.recipients()), None => { @@ -616,10 +616,10 @@ fn read_secret_text(path: &Path) -> Result { std::fs::read_to_string(path).with_context(|| format!("reading secret {}", path.display())) } -fn load_identity(path: &Path) -> Result { +async fn load_identity(path: &Path) -> Result { let text = read_secret_text(path).with_context(|| format!("age identity {}", path.display()))?; - AgeIdentity::from_secret_string(text.trim()) + AgeIdentity::from_secret_string(text.trim()).await } /// Load the config if present, else write built-in defaults; returns whether @@ -636,11 +636,11 @@ fn load_or_bootstrap_config(config_path: &Path) -> Result<(Config, bool)> { } /// Load the shared age identity, generating and persisting one on first run. -fn load_or_generate_identity(config: &Config) -> Result { +async fn load_or_generate_identity(config: &Config) -> Result { if config.age_identity_path.exists() { - load_identity(&config.age_identity_path) + load_identity(&config.age_identity_path).await } else { - let id = AgeIdentity::generate()?; + let id = AgeIdentity::generate().await?; write_secret(&config.age_identity_path, &id.to_secret_string())?; println!( "generated age identity {}", From 63e38f2ec77e6409c1699c96b6dce43fb7e87acc Mon Sep 17 00:00:00 2001 From: fosskar <117449098+fosskar@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:22:36 +0000 Subject: [PATCH 6/6] nix: address custom data directory review --- docs/DECISIONS.md | 12 +++++++----- nix/module-contract-test.nix | 26 +++++++++++++++++++++----- nix/nixos-module.nix | 21 +++++++++++++++------ nix/vm-module-test.nix | 11 +++++++++-- 4 files changed, 52 insertions(+), 18 deletions(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 7b618cc..503ac08 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -390,11 +390,13 @@ clan are strictly opt-in layers on top. **Allow-list (what the sandbox must keep open, and why):** -- `ReadWritePaths = [ sessionDir ]` — watch-and-import needs to write imported sessions - back atomically. A `systemd.tmpfiles` rule pre-creates `sessionDir` (owner = the run - user, `0700`) so the bind succeeds on first boot before the agent has created it. -- `StateDirectory=ssync` — the only other writable path (`/var/lib/ssync`: node key, blobs, - docs, index, status). +- `ReadWritePaths` includes every `sessionDir` — watch-and-import needs to write imported + sessions back atomically — plus a custom, dedicated `dataDir`, when configured. + `systemd.tmpfiles` pre-creates these paths (owner = the run user, `0700`) so the binds + succeed on first boot. Custom state paths must be nested to avoid changing ownership + of a top-level filesystem directory. +- The default data path uses `StateDirectory=ssync` for `/var/lib/ssync` (node key, + blobs, docs, index, status); custom paths replace it with the explicit writable grant. - `RestrictAddressFamilies = AF_INET AF_INET6 AF_UNIX AF_NETLINK` — iroh needs QUIC/UDP over IPv4/IPv6 and `AF_NETLINK` to enumerate local interfaces for address discovery. - secrets (`/run/secrets/…`) and the Nix store stay readable via `ProtectSystem=strict` diff --git a/nix/module-contract-test.nix b/nix/module-contract-test.nix index 6245d11..3eaaa67 100644 --- a/nix/module-contract-test.nix +++ b/nix/module-contract-test.nix @@ -7,9 +7,9 @@ let sessionDir = "/sessions"; } ]; - nixosService = + nixosConfig = extra: - (import (pkgs.path + "/nixos/lib/eval-config.nix") { + import (pkgs.path + "/nixos/lib/eval-config.nix") { system = null; modules = [ { nixpkgs.hostPlatform = pkgs.stdenv.hostPlatform; } @@ -23,7 +23,8 @@ let // extra; } ]; - }).config.systemd.services.ssync.serviceConfig; + }; + nixosService = extra: (nixosConfig extra).config.systemd.services.ssync.serviceConfig; nixosDefault = nixosService { }; nixosCustom = nixosService { dataDir = "/srv/ssync-data"; }; nixosExternal = nixosService { @@ -32,6 +33,16 @@ let nodeKeyFile = "/run/secrets/node.key"; clusterFile = "/run/secrets/cluster.toml"; }; + unsafeDataDirsRejected = + lib.all + ( + dataDir: + !(builtins.tryEval (nixosConfig { inherit dataDir; }).config.system.build.toplevel.drvPath).success + ) + [ + "/srv" + "/srv/" + ]; hmStub = { lib, ... }: @@ -86,7 +97,6 @@ let ]; }).config.systemd.user.services.ssync.Service; - words = value: if builtins.isList value then value else lib.splitString " " value; hardeningContract = service: assert service.NoNewPrivileges; @@ -106,7 +116,12 @@ let assert service.RestrictRealtime; assert service.RestrictSUIDSGID; assert - words service.RestrictAddressFamilies == [ + ( + if builtins.isList service.RestrictAddressFamilies then + service.RestrictAddressFamilies + else + lib.splitString " " service.RestrictAddressFamilies + ) == [ "AF_INET" "AF_INET6" "AF_UNIX" @@ -128,6 +143,7 @@ let assert service.UMask == "0077"; true; in +assert unsafeDataDirsRejected; assert hardeningContract nixosDefault; assert hardeningContract hmService; assert nixosDefault.StateDirectory == "ssync"; diff --git a/nix/nixos-module.nix b/nix/nixos-module.nix index fc51349..d0124eb 100644 --- a/nix/nixos-module.nix +++ b/nix/nixos-module.nix @@ -179,8 +179,8 @@ in description = '' Age identity file. If it does not exist under `dataDir`, the daemon generates one on first run. A path outside `dataDir` must already - exist and remains read-only under the sandbox. Shared mode - (`recipients = []`): it must be the *same* key on every machine, so + exist; this option does not add a writable sandbox grant for it. + Shared mode (`recipients = []`): it must be the *same* key on every machine, so point this at a secret you distribute yourself (e.g. sops-nix). Per-machine mode: each machine keeps its own key and lists the other machines' recipients in `recipients`. The clan service handles @@ -193,8 +193,9 @@ in default = "/var/lib/ssync"; description = '' ssync's own managed state (node key, blobs, docs, index). The default - uses systemd's `StateDirectory`; a custom path is created 0700 for - `user` and added to the sandbox's writable allow-list. + uses systemd's `StateDirectory`; a custom path must be a dedicated + nested directory. It is set to mode 0700, owned by `user`, and added + to the sandbox's writable allow-list. ''; }; @@ -207,7 +208,7 @@ in Manage it with `ssync cluster`, or let the clan service generate it via clan.vars. When set, peers join one deterministic namespace with no ticket exchange; mutually exclusive with `recipients`. A path - outside `dataDir` must be pre-provisioned and remains read-only. + outside `dataDir` must exist; this option does not make it writable. ''; }; @@ -216,7 +217,7 @@ in default = null; description = '' Override the iroh node key path (default: `dataDir/node.key`). A path - outside `dataDir` must be pre-provisioned and remains read-only. + outside `dataDir` must exist; this option does not make it writable. ''; }; @@ -335,6 +336,14 @@ in config = lib.mkIf cfg.enable { assertions = [ + { + assertion = + !customDataDir + || ( + lib.hasPrefix "/" cfg.dataDir && !lib.hasSuffix "/" cfg.dataDir && builtins.dirOf cfg.dataDir != "/" + ); + message = "services.ssync.dataDir must be an absolute, dedicated nested directory"; + } { assertion = !cfg.autoCleanup.enable || cfg.autoCleanup.keep != null || cfg.autoCleanup.unnamed; message = "services.ssync.autoCleanup selects nothing: set keep or unnamed"; diff --git a/nix/vm-module-test.nix b/nix/vm-module-test.nix index 2eab7c1..09f51e3 100644 --- a/nix/vm-module-test.nix +++ b/nix/vm-module-test.nix @@ -60,16 +60,22 @@ pkgs.testers.runNixOSTest { { pkgs, ... }: { imports = [ self.nixosModules.default ]; + users.groups.ssync-test = { }; + users.users.ssync-test = { + isSystemUser = true; + group = "ssync-test"; + }; services.ssync = { enable = true; - user = "root"; + user = "ssync-test"; ageIdentityFile = "/run/ssync-test-key/age.key"; dataDir = "/srv/ssync-data"; }; system.activationScripts.ssyncExternalAge = '' - install -d -m 0700 /run/ssync-test-key + install -d -m 0700 -o ssync-test -g ssync-test /run/ssync-test-key ${pkgs.age}/bin/age-keygen -pq -o /run/ssync-test-key/age.key chmod 0600 /run/ssync-test-key/age.key + chown ssync-test:ssync-test /run/ssync-test-key/age.key ''; }; @@ -105,6 +111,7 @@ pkgs.testers.runNixOSTest { custom.wait_for_file("/run/ssync-test-key/age.key") custom.wait_for_file("/srv/ssync-data/ticket") custom.succeed("test $(stat -c%a /srv/ssync-data) = 700") + custom.succeed("test $(stat -c%U /srv/ssync-data) = ssync-test") custom.succeed("systemctl show ssync -p ReadWritePaths --value | grep -q /srv/ssync-data") custom.succeed("systemctl show ssync -p ReadWritePaths --value | grep -qv /run/ssync-test-key") custom.succeed("test -z \"$(systemctl show ssync -p StateDirectory --value)\"")