diff --git a/README.md b/README.md index 1097997..320473a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ We are building the wedge primitive: - [x] Signed state registry (local content-addressed, `taproot registry push/pull/list`) - [x] Key management (`taproot keys generate/list/rotate`) - [x] Managed fabric + registry API (`taproot serve`, `taproot remote`, `taproot fabric` audit/policy/tokens) +- [x] Drift loop (v0.1.0): writable `env` file in the mount, drift captured on unmount, `taproot sync` to review, re-sign, and adopt ## Open source diff --git a/src/cli.rs b/src/cli.rs index f68b034..11ee84a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -47,12 +47,14 @@ pub struct Cli { pub enum Commands { /// Initialise a new taproot state snapshot Init(InitArgs), - /// Mount a taproot state (v0.0.1: read-only FUSE) + /// Mount a taproot state (env file writable; edits captured as drift) Mount(MountArgs), /// Show current state status Status(StatusArgs), /// Verify state signature and hash Verify(VerifyArgs), + /// Review captured drift and re-sign it into the current state + Sync(SyncArgs), /// Check current state against a baseline for drift (strict) Check(CheckArgs), /// Local signed state registry (content-addressed) @@ -102,6 +104,37 @@ pub struct MountArgs { /// Disable FUSE mount — just print header and exit (useful in CI without FUSE) #[arg(long = "no-fuse", default_value_t = false)] pub no_fuse: bool, + + /// Where to write captured drift (default: state.drift.json next to the state file) + #[arg(long, value_name = "PATH")] + pub drift_out: Option, +} + +#[derive(Debug, Args)] +pub struct SyncArgs { + /// Path to baseline state file to re-sign into (default: .taproot/state.json) + #[arg(long, value_name = "PATH")] + pub state_path: Option, + + /// Path to drifted state to adopt (default: state.drift.json next to the state file) + #[arg(long, value_name = "PATH")] + pub from: Option, + + /// Show the diff report without adopting anything + #[arg(long = "dry-run", default_value_t = false)] + pub dry_run: bool, + + /// Adopt drift that touches non-env fields (base, runtimes, containers) + #[arg(long, default_value_t = false)] + pub force: bool, + + /// Skip signing (store hash only, no ed25519 signature) + #[arg(long = "no-sign", default_value_t = false)] + pub no_sign: bool, + + /// Keep the drift file after a successful sync + #[arg(long, default_value_t = false, conflicts_with = "dry_run")] + pub keep: bool, } #[derive(Debug, Args)] @@ -519,7 +552,12 @@ pub fn handle_init(args: InitArgs) -> Result<(), TaprootError> { )) }) { Ok((k, info)) => (k, Some(info)), - Err(_) => (StateEngine::generate_keypair().0, None), + Err(e) => { + println!( + "warning: could not load default key ({e}) — signing with ephemeral key" + ); + (StateEngine::generate_keypair().0, None) + } } } else { (StateEngine::generate_keypair().0, None) @@ -575,6 +613,79 @@ pub fn handle_init(args: InitArgs) -> Result<(), TaprootError> { Ok(()) } +fn default_drift_path(state_path: &Path) -> PathBuf { + match state_path.parent() { + Some(p) if !p.as_os_str().is_empty() => p.join("state.drift.json"), + _ => PathBuf::from("state.drift.json"), + } +} + +/// Refuse when two paths resolve to the same file — e.g. `sync --from` +/// pointing at the baseline itself, or `--drift-out` overwriting it. +fn ensure_distinct(a: &Path, b: &Path, what: &str) -> Result<(), TaprootError> { + // canonicalize resolves symlinks; fall back to cwd-relative absolutization + // for paths that don't exist yet + let resolve = |p: &Path| -> PathBuf { + p.canonicalize().unwrap_or_else(|_| match p.is_absolute() { + true => p.to_path_buf(), + false => std::env::current_dir().unwrap_or_default().join(p), + }) + }; + if resolve(a) == resolve(b) { + return Err(TaprootError::Mount(format!( + "{what} points at the baseline state file itself ({}): refusing", + display_state_path(b) + ))); + } + Ok(()) +} + +/// Sign a state for adoption: stored default key if present, else ephemeral; +/// or hash-only with --no-sign. +pub fn sign_state_with_keys( + state: TaprootState, + no_sign: bool, + keys_path: &Path, +) -> Result { + if no_sign { + let hash = StateEngine::hash(&state)?; + return Ok(crate::state::SignedState { + state, + hash, + signature: None, + public_key: None, + }); + } + if keys_path.exists() { + match crate::keys::KeyStore::init(keys_path) { + Ok(ks) => match ks.default_key() { + Ok(kp) => { + let preview = &kp.public_key[..16.min(kp.public_key.len())]; + println!("signing with key {} ({preview})", kp.id); + return StateEngine::sign(&state, &kp.private_key); + } + Err(e) => println!( + "warning: could not load default key ({e}) — signing with ephemeral key" + ), + }, + Err(e) => { + println!("warning: could not open keystore ({e}) — signing with ephemeral key") + } + } + } else { + println!("signing with ephemeral key (no keys found, run `taproot keys generate`)"); + } + let (priv_key, _) = StateEngine::generate_keypair(); + StateEngine::sign(&state, &priv_key) +} + +fn sign_for_adoption( + state: TaprootState, + no_sign: bool, +) -> Result { + sign_state_with_keys(state, no_sign, &resolve_keys_path(None)) +} + pub fn handle_mount(args: MountArgs) -> Result<(), TaprootError> { let state_path = resolve_state_path(args.state_path); tracing::info!(?state_path, ?args.path, "mount"); @@ -656,12 +767,55 @@ pub fn handle_mount(args: MountArgs) -> Result<(), TaprootError> { } println!( - "attempting FUSE mount at {} (read-only, Ctrl-C to unmount)...", + "attempting FUSE mount at {} (env writable, Ctrl-C to unmount)...", args.path.display() ); + let drift_path = args + .drift_out + .clone() + .unwrap_or_else(|| default_drift_path(&state_path)); + ensure_distinct(&state_path, &drift_path, "--drift-out")?; match crate::mount::mount_readonly(&args.path, &signed) { - Ok(()) => { + Ok(outcome) => { print_status_line(true); + if let Some(drift) = outcome.drift { + if let Err(e) = StateEngine::save(&drift_path, &drift) { + eprintln!( + "✗ mounted OK, but failed to save drift to {}: {e}", + display_state_path(&drift_path) + ); + return Err(e); + } + println!(); + println!("drift: captured — env was edited during the mount"); + println!("path: {}", display_state_path(&drift_path)); + println!("[next: taproot sync to review, sign, and adopt]"); + } else if let Some(raw) = outcome.raw_env { + let raw_path = drift_path.with_extension("env.txt"); + let parse_msg = outcome + .parse_error + .as_ref() + .map(|e| e.to_string()) + .unwrap_or_else(|| "unparseable env".to_string()); + match std::fs::write(&raw_path, &raw) { + Ok(()) => { + println!(); + println!("drift: ⚠ env was edited but could not be read: {parse_msg}"); + println!( + "raw: session preserved in {}", + display_state_path(&raw_path) + ); + println!("[inspect the raw env file, fix or restore, then re-sign]"); + } + Err(e) => { + eprintln!( + "✗ mounted OK, but failed to save raw env to {}: {e}", + display_state_path(&raw_path) + ); + return Err(TaprootError::Io(e)); + } + } + } println!(); Ok(()) } @@ -753,6 +907,111 @@ pub fn handle_verify(args: VerifyArgs) -> Result<(), TaprootError> { } } +pub fn handle_sync(args: SyncArgs) -> Result<(), TaprootError> { + let state_path = resolve_state_path(args.state_path); + let drift_path = args + .from + .clone() + .unwrap_or_else(|| default_drift_path(&state_path)); + tracing::info!(?state_path, ?drift_path, "sync"); + ensure_distinct(&state_path, &drift_path, "--from")?; + + let baseline = StateEngine::load(&state_path)?; + let current = StateEngine::load(&drift_path)?; + + println!("TAPROOT SYNC"); + println!("─────────────────────────────────────────"); + println!( + "baseline: sha256:{} ({})", + baseline.hash, + display_state_path(&state_path) + ); + println!( + "drift: sha256:{} ({})", + current.hash, + display_state_path(&drift_path) + ); + println!(); + + let diffs = crate::diff::diff_states(&baseline.state, ¤t.state, false); + if diffs.is_empty() { + println!("no drift — states are identical"); + if !args.keep && std::fs::remove_file(&drift_path).is_ok() { + println!("removed: {}", display_state_path(&drift_path)); + } + return Ok(()); + } + + println!( + "drift ({} field{}):", + diffs.len(), + if diffs.len() == 1 { "" } else { "s" } + ); + for d in &diffs { + let marker = match d.kind { + crate::diff::DiffKind::Added => "+", + crate::diff::DiffKind::Removed => "-", + crate::diff::DiffKind::Changed => "~", + }; + println!(" {marker} {}", d.path); + if let Some(e) = &d.expected { + println!(" expected: {e}"); + } + if let Some(a) = &d.actual { + println!(" actual: {a}"); + } + println!(" severity: {:?}", d.severity); + } + println!(); + + if args.dry_run { + println!("dry-run — nothing adopted."); + println!("[re-run without --dry-run to sign and adopt]"); + return Ok(()); + } + + // Env-var drift is the intended writable surface — adopting it is the + // point of sync. Refuse identity drift unless --force: that usually + // means the drift file belongs to a different repo or baseline. + // Keyed on path, not severity: base.branch/base.commit are only Warning + // under non-strict diffing but still identity. + let identity_drift: Vec<&str> = diffs + .iter() + .filter(|d| { + d.path.starts_with("base.") + || d.path.starts_with("runtimes.") + || d.path.starts_with("containers.") + || d.path == "version" + || d.path == "notes" + }) + .map(|d| d.path.as_str()) + .collect(); + if !identity_drift.is_empty() && !args.force { + eprintln!( + "✗ drift touches non-env fields ({}) — refusing to adopt without --force", + identity_drift.join(", ") + ); + return Err(TaprootError::Drift { + breaking: identity_drift.len(), + warning: diffs.len() - identity_drift.len(), + }); + } + + let signed_new = sign_for_adoption(current.state.clone(), args.no_sign)?; + StateEngine::save(&state_path, &signed_new)?; + if !args.keep { + std::fs::remove_file(&drift_path)?; + } + println!("adopted: sha256:{}", signed_new.hash); + if signed_new.signature.is_none() { + print_unsigned_warning(); + } + println!("path: {}", display_state_path(&state_path)); + println!(); + print_status_line(true); + Ok(()) +} + pub fn handle_keys(args: KeysArgs) -> Result<(), TaprootError> { match args.command { KeysCommands::Generate(a) => handle_keys_generate(a), diff --git a/src/main.rs b/src/main.rs index 310d532..317e2af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use clap::Parser; use taproot::cli::{ handle_check, handle_fabric, handle_init, handle_keys, handle_mount, handle_registry, - handle_remote, handle_serve, handle_status, handle_verify, Cli, Commands, + handle_remote, handle_serve, handle_status, handle_sync, handle_verify, Cli, Commands, }; fn main() { @@ -19,6 +19,7 @@ fn main() { Commands::Mount(args) => handle_mount(args), Commands::Status(args) => handle_status(args), Commands::Verify(args) => handle_verify(args), + Commands::Sync(args) => handle_sync(args), Commands::Check(args) => handle_check(args), Commands::Registry(args) => handle_registry(args), Commands::Keys(args) => handle_keys(args), diff --git a/src/mount.rs b/src/mount.rs index 0989842..095813b 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -1,6 +1,7 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::ffi::OsStr; use std::path::Path; +use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime}; use fuser::{ @@ -27,14 +28,36 @@ struct Inode { kind: FileType, data: Vec, children: Vec, + /// Writable inodes accept writes; everything else stays read-only. + writable: bool, +} + +/// Captures writes to the env file so drift can be extracted after unmount. +#[derive(Debug, Default)] +struct Journal { + env_original: Vec, + env_data: Vec, +} + +impl Journal { + fn drifted(&self) -> bool { + self.env_data != self.env_original + } } fn now() -> SystemTime { SystemTime::now() } -fn file_attr(ino: u64, size: u64, kind: FileType) -> FileAttr { +fn file_attr(ino: u64, size: u64, kind: FileType, writable: bool) -> FileAttr { let t = now(); + let perm = if kind == FileType::Directory { + 0o555 + } else if writable { + 0o644 + } else { + 0o444 + }; FileAttr { ino, size, @@ -46,11 +69,7 @@ fn file_attr(ino: u64, size: u64, kind: FileType) -> FileAttr { ctime: t, crtime: t, kind, - perm: if kind == FileType::Directory { - 0o555 - } else { - 0o444 - }, + perm, nlink: if kind == FileType::Directory { 2 } else { 1 }, uid: unsafe { libc::getuid() } as u32, gid: unsafe { libc::getgid() } as u32, @@ -83,6 +102,8 @@ pub struct TaprootFS { /// (parent_ino, name) -> ino lookup: HashMap<(u64, String), u64>, next_ino: u64, + env_ino: Option, + journal: Arc>, } impl TaprootFS { @@ -91,6 +112,8 @@ impl TaprootFS { inodes: HashMap::new(), lookup: HashMap::new(), next_ino: ROOT_INO + 1, + env_ino: None, + journal: Arc::new(Mutex::new(Journal::default())), }; fs.inodes.insert( @@ -102,6 +125,7 @@ impl TaprootFS { kind: FileType::Directory, data: Vec::new(), children: Vec::new(), + writable: false, }, ); @@ -109,14 +133,21 @@ impl TaprootFS { let state_json = serde_json::to_string_pretty(signed).unwrap_or_else(|_| "{}".into()); let env_content = Self::env_content(signed); - fs.add_file(ROOT_INO, "README.taproot", readme.into_bytes()); - fs.add_file(ROOT_INO, "state.json", state_json.into_bytes()); - fs.add_file(ROOT_INO, "env", env_content.into_bytes()); - fs.add_file(ROOT_INO, "hash", signed.hash.clone().into_bytes()); + fs.add_file(ROOT_INO, "README.taproot", readme.into_bytes(), false); + fs.add_file(ROOT_INO, "state.json", state_json.into_bytes(), false); + let env_ino = fs.add_file(ROOT_INO, "env", env_content.clone().into_bytes(), true); + fs.env_ino = Some(env_ino); + { + let mut j = fs.journal.lock().unwrap(); + j.env_original = env_content.into_bytes(); + j.env_data = j.env_original.clone(); + } + fs.add_file(ROOT_INO, "hash", signed.hash.clone().into_bytes(), false); fs.add_file( ROOT_INO, "version", signed.state.version.clone().into_bytes(), + false, ); let runtimes_ino = fs.add_dir(ROOT_INO, "runtimes"); @@ -132,7 +163,7 @@ impl TaprootFS { if fs.lookup.contains_key(&(runtimes_ino, fname.clone())) { continue; } - fs.add_file(runtimes_ino, &fname, content.into_bytes()); + fs.add_file(runtimes_ino, &fname, content.into_bytes(), false); } let containers_ino = fs.add_dir(ROOT_INO, "containers"); @@ -148,7 +179,7 @@ impl TaprootFS { if fs.lookup.contains_key(&(containers_ino, fname.clone())) { continue; } - fs.add_file(containers_ino, &fname, content.into_bytes()); + fs.add_file(containers_ino, &fname, content.into_bytes(), false); } fs @@ -162,16 +193,20 @@ impl TaprootFS { "unsigned" }; format!( - "taproot read-only mount\n\ - =======================\n\ + "taproot mount\n\ + =============\n\ repo: {} branch: {} commit: {}\n\ state: {sig} sha256:{}\n\ runtimes: {} containers: {} env-vars: {}\n\ \n\ - This filesystem is read-only. All writes return EROFS.\n\ + Read-only, except one file:\n\ + env — edit key=value lines to change env-vars.\n\ + On unmount, edits are captured as drift; run\n\ + `taproot sync` to review, sign, and adopt them.\n\ + \n\ Files:\n\ state.json — pretty-printed SignedState\n\ - env — key=value list\n\ + env — key=value list (WRITABLE)\n\ hash — sha256 hex\n\ version — schema version\n\ runtimes/ — per-runtime virtual files\n\ @@ -200,7 +235,7 @@ impl TaprootFS { out } - fn add_file(&mut self, parent: u64, name: &str, data: Vec) -> u64 { + fn add_file(&mut self, parent: u64, name: &str, data: Vec, writable: bool) -> u64 { let ino = self.next_ino; self.next_ino += 1; let inode = Inode { @@ -210,6 +245,7 @@ impl TaprootFS { kind: FileType::RegularFile, data, children: Vec::new(), + writable, }; self.inodes.insert(ino, inode); self.lookup.insert((parent, name.to_string()), ino); @@ -229,6 +265,7 @@ impl TaprootFS { kind: FileType::Directory, data: Vec::new(), children: Vec::new(), + writable: false, }; self.inodes.insert(ino, inode); self.lookup.insert((parent, name.to_string()), ino); @@ -261,7 +298,53 @@ impl TaprootFS { } else { inode.data.len() as u64 }; - Some(file_attr(ino, size, inode.kind)) + Some(file_attr(ino, size, inode.kind, inode.writable)) + } + + /// Apply a write to a writable inode. Shared by the FUSE `write` handler + /// and tests so the journal/overlay logic is testable without a mount. + fn apply_write(&mut self, ino: u64, offset: i64, data: &[u8]) -> Result { + let inode = self.inodes.get_mut(&ino).ok_or(libc::ENOENT)?; + if inode.kind == FileType::Directory { + return Err(libc::EISDIR); + } + if !inode.writable { + return Err(EROFS); + } + if offset < 0 { + return Err(libc::EINVAL); + } + let off = offset as usize; + if off > inode.data.len() { + inode.data.resize(off, 0); + } + let end = off + data.len(); + if end > inode.data.len() { + inode.data.resize(end, 0); + } + inode.data[off..end].copy_from_slice(data); + if self.env_ino == Some(ino) { + let mut j = self.journal.lock().unwrap(); + j.env_data = inode.data.clone(); + } + Ok(data.len() as u32) + } + + /// Truncate/extend a writable inode to `size` (FUSE setattr with size). + fn apply_truncate(&mut self, ino: u64, size: u64) -> Result<(), libc::c_int> { + let inode = self.inodes.get_mut(&ino).ok_or(libc::ENOENT)?; + if inode.kind == FileType::Directory { + return Err(libc::EISDIR); + } + if !inode.writable { + return Err(EROFS); + } + inode.data.resize(size as usize, 0); + if self.env_ino == Some(ino) { + let mut j = self.journal.lock().unwrap(); + j.env_data = inode.data.clone(); + } + Ok(()) } } @@ -294,20 +377,25 @@ impl Filesystem for TaprootFS { } fn open(&mut self, _req: &Request<'_>, ino: u64, flags: i32, reply: ReplyOpen) { - if (flags & libc::O_TRUNC) != 0 || (flags & libc::O_CREAT) != 0 { - reply.error(EROFS); + let Some(inode) = self.inodes.get(&ino) else { + reply.error(libc::ENOENT); return; - } - let accmode = flags & libc::O_ACCMODE; - if accmode == libc::O_WRONLY || accmode == libc::O_RDWR { + }; + let wants_write = { + let accmode = flags & libc::O_ACCMODE; + (flags & libc::O_TRUNC) != 0 || accmode == libc::O_WRONLY || accmode == libc::O_RDWR + }; + if wants_write && !inode.writable { reply.error(EROFS); return; } - if self.inodes.contains_key(&ino) { - reply.opened(0, 0); - } else { - reply.error(libc::ENOENT); + if (flags & libc::O_TRUNC) != 0 { + if let Err(e) = self.apply_truncate(ino, 0) { + reply.error(e); + return; + } } + reply.opened(0, 0); } fn read( @@ -406,22 +494,25 @@ impl Filesystem for TaprootFS { reply.ok(); } - // --- read-only denials --- fn write( &mut self, _req: &Request<'_>, - _ino: u64, + ino: u64, _fh: u64, - _offset: i64, - _data: &[u8], + offset: i64, + data: &[u8], _write_flags: u32, _flags: i32, _lock_owner: Option, reply: ReplyWrite, ) { - reply.error(EROFS); + match self.apply_write(ino, offset, data) { + Ok(n) => reply.written(n), + Err(e) => reply.error(e), + } } + // --- read-only denials --- fn create( &mut self, _req: &Request<'_>, @@ -484,11 +575,11 @@ impl Filesystem for TaprootFS { fn setattr( &mut self, _req: &Request<'_>, - _ino: u64, + ino: u64, _mode: Option, _uid: Option, _gid: Option, - _size: Option, + size: Option, _atime: Option, _mtime: Option, _ctime: Option, @@ -499,6 +590,23 @@ impl Filesystem for TaprootFS { _flags: Option, reply: ReplyAttr, ) { + // Only size changes on writable inodes are honored (truncate). + if let Some(sz) = size { + match self.apply_truncate(ino, sz) { + Ok(()) => { + if let Some(attr) = self.getattr_for(ino) { + reply.attr(&TTL, &attr); + } else { + reply.error(libc::ENOENT); + } + return; + } + Err(e) => { + reply.error(e); + return; + } + } + } reply.error(EROFS); } } @@ -507,10 +615,80 @@ impl Filesystem for TaprootFS { // Public mount helper // --------------------------------------------------------------------------- -/// Mount a read-only FUSE filesystem at `mountpoint` reflecting `signed`. +// --------------------------------------------------------------------------- +// Drift extraction +// --------------------------------------------------------------------------- + +/// Parse a `key=value` env file back into a map. Blank lines are skipped; +/// anything without `=` is an error. +pub fn parse_env(content: &str) -> Result, TaprootError> { + let mut map = BTreeMap::new(); + for (idx, line) in content.lines().enumerate() { + let line = line.trim_end_matches('\r'); + if line.trim().is_empty() { + continue; + } + let Some((k, v)) = line.split_once('=') else { + return Err(TaprootError::Mount(format!( + "malformed env line {}: expected key=value", + idx + 1 + ))); + }; + let k = k.trim(); + if k.is_empty() { + return Err(TaprootError::Mount(format!( + "malformed env line {}: empty key", + idx + 1 + ))); + } + map.insert(k.to_string(), v.to_string()); + } + Ok(map) +} + +/// Build a drifted (unsigned) SignedState from the mounted env file contents. +/// The new state gets a fresh hash; signature is dropped because the state +/// no longer matches the signed baseline. +pub fn extract_env_drift( + signed: &SignedState, + env_content: &[u8], +) -> Result { + let text = std::str::from_utf8(env_content) + .map_err(|e| TaprootError::Mount(format!("env file is not utf-8: {e}")))?; + let env_vars = parse_env(text)?; + let mut state = signed.state.clone(); + state.env_vars = env_vars; + state.created_at = chrono::Utc::now(); + let hash = crate::engine::StateEngine::hash(&state)?; + Ok(SignedState { + state, + hash, + signature: None, + public_key: None, + }) +} + +/// Result of a mount session after unmount. +#[derive(Debug)] +pub struct MountOutcome { + /// Present when the env file was edited and parsed back into a state. + pub drift: Option, + /// Raw edited env bytes, present when parsing into a state failed. + /// The session's edits survive in these bytes even though no state + /// could be built from them. + pub raw_env: Option>, + /// Parse error accompanying `raw_env`. + pub parse_error: Option, +} + +/// Mount a FUSE filesystem at `mountpoint` reflecting `signed`. /// -/// Blocks until unmounted. Mount options: RO, FSName("taproot"). -pub fn mount_readonly(mountpoint: &Path, signed: &SignedState) -> Result<(), TaprootError> { +/// Blocks until unmounted. Read-only everywhere except the `env` file; +/// edits to `env` are returned as drift in the outcome. +pub fn mount_readonly( + mountpoint: &Path, + signed: &SignedState, +) -> Result { let meta = std::fs::symlink_metadata(mountpoint).map_err(|e| { TaprootError::Mount(format!( "mountpoint does not exist: {}: {e}", @@ -530,13 +708,43 @@ pub fn mount_readonly(mountpoint: &Path, signed: &SignedState) -> Result<(), Tap ))); } let fs = TaprootFS::new(signed); + let journal = Arc::clone(&fs.journal); let options = [ - fuser::MountOption::RO, fuser::MountOption::FSName("taproot".to_string()), fuser::MountOption::Subtype("taproot".to_string()), ]; fuser::mount2(fs, mountpoint, &options).map_err(|e| TaprootError::Mount(e.to_string()))?; - Ok(()) + + let j = journal.lock().unwrap(); + Ok(outcome_from_journal(&j, signed)) +} + +/// Decide the mount outcome from the journal. Extracted from +/// `mount_readonly` so the drift/raw-env branches are testable without a +/// real mount. +fn outcome_from_journal(j: &Journal, signed: &SignedState) -> MountOutcome { + if j.drifted() { + match extract_env_drift(signed, &j.env_data) { + Ok(drift) => MountOutcome { + drift: Some(drift), + raw_env: None, + parse_error: None, + }, + // Unparseable env must not destroy the session: hand back the + // raw bytes so the CLI can persist them. + Err(e) => MountOutcome { + drift: None, + raw_env: Some(j.env_data.clone()), + parse_error: Some(e), + }, + } + } else { + MountOutcome { + drift: None, + raw_env: None, + parse_error: None, + } + } } // --------------------------------------------------------------------------- @@ -608,6 +816,102 @@ mod tests { assert_eq!(attr.perm, 0o555); } + #[test] + fn env_is_writable_others_are_not() { + let signed = sample_signed(); + let mut fs = TaprootFS::new(&signed); + let env_ino = fs.lookup_ino(ROOT_INO, "env").unwrap(); + let state_ino = fs.lookup_ino(ROOT_INO, "state.json").unwrap(); + + assert_eq!(fs.getattr_for(env_ino).unwrap().perm, 0o644); + assert_eq!(fs.getattr_for(state_ino).unwrap().perm, 0o444); + + // env accepts writes + let n = fs.apply_write(env_ino, 0, b"FOO=baz\n").unwrap(); + assert_eq!(n, 8); + // state.json rejects writes + assert_eq!(fs.apply_write(state_ino, 0, b"x"), Err(EROFS)); + } + + #[test] + fn env_write_updates_journal_and_drift_extracts() { + let signed = sample_signed(); + let mut fs = TaprootFS::new(&signed); + let env_ino = fs.lookup_ino(ROOT_INO, "env").unwrap(); + + // rewrite whole file via truncate + write + fs.apply_truncate(env_ino, 0).unwrap(); + fs.apply_write(env_ino, 0, b"FOO=baz\nNEW=1\n").unwrap(); + + { + let j = fs.journal.lock().unwrap(); + assert!(j.drifted()); + let drift = extract_env_drift(&signed, &j.env_data).unwrap(); + assert_eq!(drift.state.env_vars.get("FOO").unwrap(), "baz"); + assert_eq!(drift.state.env_vars.get("NEW").unwrap(), "1"); + assert!(drift.signature.is_none()); + assert_ne!(drift.hash, signed.hash); + // untouched fields survive + assert_eq!(drift.state.runtimes, signed.state.runtimes); + } + } + + #[test] + fn identical_env_rewrite_is_not_drift() { + let signed = sample_signed(); + let mut fs = TaprootFS::new(&signed); + let env_ino = fs.lookup_ino(ROOT_INO, "env").unwrap(); + // rewrite the exact original content + let original: Vec = fs + .get_inode(env_ino) + .map(|i| i.data.clone()) + .unwrap_or_default(); + fs.apply_truncate(env_ino, 0).unwrap(); + fs.apply_write(env_ino, 0, &original).unwrap(); + assert!(!fs.journal.lock().unwrap().drifted()); + } + + #[test] + fn parse_env_rejects_malformed_lines() { + assert!(parse_env("A=1\nB=2\n").is_ok()); + assert!(parse_env("\n\nA=1\n").is_ok()); + assert!(parse_env("=1\n").is_err()); + assert!(parse_env("JUSTKEY\n").is_err()); + // value may contain '=' + let m = parse_env("URL=http://x?a=b\n").unwrap(); + assert_eq!(m.get("URL").unwrap(), "http://x?a=b"); + } + + #[test] + fn outcome_preserves_unparseable_env_as_raw_bytes() { + let signed = sample_signed(); + let mut fs = TaprootFS::new(&signed); + let env_ino = fs.lookup_ino(ROOT_INO, "env").unwrap(); + // binary garbage with no newline and no '=' + fs.apply_truncate(env_ino, 0).unwrap(); + fs.apply_write(env_ino, 0, &[0xFF, 0xFE, 0x00, 0x01]) + .unwrap(); + + let j = fs.journal.lock().unwrap(); + let outcome = outcome_from_journal(&j, &signed); + assert!(outcome.drift.is_none()); + assert!(outcome.raw_env.is_some()); + assert!(outcome.parse_error.is_some()); + assert_eq!( + outcome.raw_env.as_deref().unwrap(), + &[0xFF, 0xFE, 0x00, 0x01] + ); + + // clean journal → no drift, no raw + drop(j); + let fs2 = TaprootFS::new(&signed); + let j2 = fs2.journal.lock().unwrap(); + let outcome2 = outcome_from_journal(&j2, &signed); + assert!(outcome2.drift.is_none()); + assert!(outcome2.raw_env.is_none()); + assert!(outcome2.parse_error.is_none()); + } + #[test] fn mount_readonly_errors_on_missing_path() { let signed = sample_signed(); diff --git a/tests/cli.rs b/tests/cli.rs index 3eb9285..741b094 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,6 +1,6 @@ use taproot::cli::{ - handle_check, handle_init, handle_mount, handle_status, handle_verify, CheckArgs, InitArgs, - MountArgs, + handle_check, handle_init, handle_mount, handle_status, handle_sync, handle_verify, CheckArgs, + InitArgs, MountArgs, SyncArgs, }; fn temp_dir() -> tempfile::TempDir { @@ -72,6 +72,7 @@ fn mount_rejects_symlink_even_with_no_fuse() { path: link, state_path: Some(state_path), no_fuse: true, + drift_out: None, }; assert!(handle_mount(args).is_err()); } @@ -96,6 +97,7 @@ fn mount_no_fuse_succeeds_on_valid_dir() { path: mnt, state_path: Some(state_path), no_fuse: true, + drift_out: None, }; assert!(handle_mount(args).is_ok()); } @@ -273,3 +275,268 @@ fn status_and_verify_roundtrip() { }) .is_ok()); } + +// --------------------------------------------------------------------------- +// sync — adopt drift, re-sign +// --------------------------------------------------------------------------- + +fn sync_setup() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { + let dir = temp_dir(); + let state_path = dir.path().join("state.json"); + let drift_path = dir.path().join("state.drift.json"); + handle_init(InitArgs { + repo: "myapp".into(), + branch: "main".into(), + commit: "abc123".into(), + state_path: Some(state_path.clone()), + no_sign: false, + }) + .unwrap(); + (dir, state_path, drift_path) +} + +fn write_drift_from_env(state_path: &std::path::Path, drift_path: &std::path::Path, env: &str) { + use taproot::mount::extract_env_drift; + let baseline = taproot::StateEngine::load(state_path).unwrap(); + let drift = extract_env_drift(&baseline, env.as_bytes()).unwrap(); + taproot::StateEngine::save(drift_path, &drift).unwrap(); +} + +#[test] +fn sync_dry_run_reports_but_does_not_adopt() { + let (_dir, state_path, drift_path) = sync_setup(); + write_drift_from_env(&state_path, &drift_path, "FOO=baz\nNEW=1\n"); + let before = taproot::StateEngine::load(&state_path).unwrap().hash; + + assert!(handle_sync(SyncArgs { + state_path: Some(state_path.clone()), + from: None, + dry_run: true, + force: false, + no_sign: true, + keep: false, + }) + .is_ok()); + + let after = taproot::StateEngine::load(&state_path).unwrap().hash; + assert_eq!(before, after, "dry-run must not change state"); + assert!(drift_path.exists(), "dry-run must keep drift file"); +} + +#[test] +fn sync_adopts_drift_and_resigns() { + let (_dir, state_path, drift_path) = sync_setup(); + let baseline_hash = taproot::StateEngine::load(&state_path).unwrap().hash; + write_drift_from_env(&state_path, &drift_path, "FOO=baz\nNEW=1\n"); + + assert!(handle_sync(SyncArgs { + state_path: Some(state_path.clone()), + from: None, + dry_run: false, + force: false, + no_sign: true, + keep: false, + }) + .is_ok()); + + let adopted = taproot::StateEngine::load(&state_path).unwrap(); + assert_eq!(adopted.state.env_vars.get("NEW").unwrap(), "1"); + assert_eq!(adopted.state.env_vars.get("FOO").unwrap(), "baz"); + assert_ne!(adopted.hash, baseline_hash); + assert!(!drift_path.exists(), "drift file removed after sync"); + + // check against the pre-sync baseline must now see no drift from adopted state + // (baseline was replaced in place, so verify round-trips) + assert!(handle_verify(taproot::cli::VerifyArgs { + state_path: Some(state_path) + }) + .is_ok()); +} + +#[test] +fn sync_errors_without_drift_file() { + let (_dir, state_path, _drift_path) = sync_setup(); + assert!(handle_sync(SyncArgs { + state_path: Some(state_path), + from: None, + dry_run: false, + force: false, + no_sign: true, + keep: false, + }) + .is_err()); +} + +#[test] +fn sync_identical_states_cleans_up_drift_file() { + let (_dir, state_path, drift_path) = sync_setup(); + // drift with identical content — extract produces same env, but new + // created_at; diff ignores created_at so this is "no drift" + write_drift_from_env(&state_path, &drift_path, "FOO=bar\n"); + + assert!(handle_sync(SyncArgs { + state_path: Some(state_path), + from: None, + dry_run: false, + force: false, + no_sign: true, + keep: false, + }) + .is_ok()); + assert!(!drift_path.exists()); +} + +#[test] +fn sync_refuses_from_pointing_at_state_file() { + let (_dir, state_path, _drift_path) = sync_setup(); + let before = taproot::StateEngine::load(&state_path).unwrap().hash; + assert!(handle_sync(SyncArgs { + state_path: Some(state_path.clone()), + from: Some(state_path.clone()), + dry_run: false, + force: false, + no_sign: true, + keep: false, + }) + .is_err()); + // baseline must survive untouched + let after = taproot::StateEngine::load(&state_path).unwrap().hash; + assert_eq!(before, after); +} + +#[test] +fn sync_refuses_identity_drift_without_force() { + use taproot::{StateEngine, TaprootState}; + let (_dir, state_path, drift_path) = sync_setup(); + // drift for a DIFFERENT repo — self-consistent, but foreign + let state = TaprootState::new("otherapp", "main", "abc123").with_env("FOO", "bar"); + let hash = StateEngine::hash(&state).unwrap(); + taproot::StateEngine::save( + &drift_path, + &taproot::SignedState { + state, + hash, + signature: None, + public_key: None, + }, + ) + .unwrap(); + + assert!(handle_sync(SyncArgs { + state_path: Some(state_path.clone()), + from: None, + dry_run: false, + force: false, + no_sign: true, + keep: false, + }) + .is_err()); + // --force opts in + assert!(handle_sync(SyncArgs { + state_path: Some(state_path.clone()), + from: None, + dry_run: false, + force: true, + no_sign: true, + keep: false, + }) + .is_ok()); + let adopted = taproot::StateEngine::load(&state_path).unwrap(); + assert_eq!(adopted.state.base.repo, "otherapp"); +} + +#[test] +fn sync_refuses_branch_commit_drift_without_force() { + use taproot::{StateEngine, TaprootState}; + let (_dir, state_path, drift_path) = sync_setup(); + // same repo, different commit — base.commit is only a Warning under + // non-strict diffing, so the gate must catch it by path, not severity + let state = TaprootState::new("myapp", "main", "deadbeef").with_env("FOO", "bar"); + let hash = StateEngine::hash(&state).unwrap(); + taproot::StateEngine::save( + &drift_path, + &taproot::SignedState { + state, + hash, + signature: None, + public_key: None, + }, + ) + .unwrap(); + + assert!(handle_sync(SyncArgs { + state_path: Some(state_path.clone()), + from: None, + dry_run: false, + force: false, + no_sign: true, + keep: false, + }) + .is_err()); + // drift file preserved for --force + assert!(drift_path.exists()); + assert!(handle_sync(SyncArgs { + state_path: Some(state_path.clone()), + from: None, + dry_run: false, + force: true, + no_sign: true, + keep: false, + }) + .is_ok()); + let adopted = taproot::StateEngine::load(&state_path).unwrap(); + assert_eq!(adopted.state.base.commit, "deadbeef"); +} + +#[test] +fn sign_state_with_keys_uses_stored_key() { + use taproot::cli::sign_state_with_keys; + use taproot::keys::KeyStore; + let dir = temp_dir(); + let keys_root = dir.path().join("keys"); + KeyStore::init(&keys_root).unwrap(); + let kp = KeyStore::new(&keys_root).generate(None).unwrap(); + + let state = taproot::TaprootState::new("myapp", "main", "abc123"); + let signed = sign_state_with_keys(state, false, &keys_root).unwrap(); + assert!(signed.signature.is_some()); + assert_eq!(signed.public_key.as_deref(), Some(kp.public_key.as_str())); + taproot::StateEngine::verify(&signed).unwrap(); +} + +#[test] +fn sign_state_with_keys_ephemeral_and_no_sign() { + use taproot::cli::sign_state_with_keys; + let dir = temp_dir(); + let keys_root = dir.path().join("no-keys-here"); + + let state = taproot::TaprootState::new("myapp", "main", "abc123"); + // no keystore on disk → ephemeral, still a valid signed state + let signed = sign_state_with_keys(state.clone(), false, &keys_root).unwrap(); + assert!(signed.signature.is_some()); + assert!(signed.public_key.is_some()); + taproot::StateEngine::verify(&signed).unwrap(); + + // --no-sign → hash only + let unsigned = sign_state_with_keys(state, true, &keys_root).unwrap(); + assert!(unsigned.signature.is_none()); + taproot::StateEngine::verify(&unsigned).unwrap(); +} + +#[test] +fn extract_env_drift_rejects_non_utf8() { + use taproot::mount::extract_env_drift; + let dir = temp_dir(); + let state_path = dir.path().join("state.json"); + handle_init(InitArgs { + repo: "myapp".into(), + branch: "main".into(), + commit: "abc123".into(), + state_path: Some(state_path.clone()), + no_sign: true, + }) + .unwrap(); + let baseline = taproot::StateEngine::load(&state_path).unwrap(); + let raw = [0xFFu8, 0xFE, b'A', b'=', b'1']; + assert!(extract_env_drift(&baseline, &raw).is_err()); +}