diff --git a/crates/loomweave-cli/Cargo.toml b/crates/loomweave-cli/Cargo.toml index 6349ec28..767e574b 100644 --- a/crates/loomweave-cli/Cargo.toml +++ b/crates/loomweave-cli/Cargo.toml @@ -37,6 +37,7 @@ serde_json.workspace = true serde_norway.workspace = true sha2.workspace = true subtle.workspace = true +tempfile.workspace = true thiserror.workspace = true time.workspace = true toml.workspace = true @@ -66,7 +67,6 @@ loomweave-plugin-fixture = { path = "../loomweave-plugin-fixture" } rusqlite.workspace = true serde_json.workspace = true sha1.workspace = true -tempfile.workspace = true # Targeted `kill(pid, SIGTERM/SIGINT)` in tests/serve.rs — pid-addressed, # never the process group (which would take the test runner down with it). diff --git a/crates/loomweave-cli/src/atomic_fs.rs b/crates/loomweave-cli/src/atomic_fs.rs new file mode 100644 index 00000000..ae4797e2 --- /dev/null +++ b/crates/loomweave-cli/src/atomic_fs.rs @@ -0,0 +1,167 @@ +//! Exclusive, unpredictable staging for atomic file and directory replacement. +//! +//! Every "write a sibling temp, then rename over the destination" site in the +//! CLI used to derive the staging name from the process id +//! (`.tmp-`). That name is guessable, and the staging path lives in +//! a directory the analyzed repository controls (`.claude/`, the project root, +//! `.weft/loomweave/`, a backup output directory). A repository that commits a +//! symlink at the guessed name turns the staging write into a write-through to +//! wherever the link points (`fs::write` and SQLite both follow symlinks), and +//! a planted regular file can pre-empt the rename. `O_CREAT|O_EXCL` on a +//! random name closes both: the staging file is created by this process or the +//! call fails, and nothing pre-planted is ever opened or renamed over the +//! destination. +//! +//! The helpers keep the staging entry in the destination's own directory so +//! the final rename stays a same-filesystem atomic swap, and they request +//! `0o666` at creation so the kernel applies the caller's umask — the same +//! resulting mode `fs::write` would have produced — instead of `tempfile`'s +//! owner-only default, which would silently make an installed `CLAUDE.md` or +//! `settings.json` unreadable to other users of a shared checkout. + +use std::io::Write as _; +use std::path::Path; + +use anyhow::{Context, Result}; +use tempfile::{Builder, NamedTempFile, TempDir}; + +/// Create an exclusive staging file in `dir` whose name starts with `prefix`. +/// +/// # Errors +/// +/// Returns an error if `dir` cannot be written. +pub(crate) fn staging_file_in(dir: &Path, prefix: &str) -> Result { + let mut builder = Builder::new(); + builder.prefix(prefix); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + builder.permissions(std::fs::Permissions::from_mode(0o666)); + } + builder + .tempfile_in(dir) + .with_context(|| format!("create staging file in {}", dir.display())) +} + +/// Create an exclusive, empty staging directory in `dir` whose name starts +/// with `prefix`. +/// +/// # Errors +/// +/// Returns an error if `dir` cannot be written. +pub(crate) fn staging_dir_in(dir: &Path, prefix: &str) -> Result { + Builder::new() + .prefix(prefix) + .tempdir_in(dir) + .with_context(|| format!("create staging directory in {}", dir.display())) +} + +/// Atomically replace `dest` with `bytes`: stage exclusively in `dest`'s +/// directory, then rename over it. `prefix` names the staging file so an +/// interrupted run leaves a recognisable sibling. +/// +/// # Errors +/// +/// Returns an error if the parent directory cannot be created, or the staging +/// write or the final rename fails. A failed call never leaves a staging +/// sibling behind (`NamedTempFile` unlinks on drop). +pub(crate) fn replace_file(dest: &Path, prefix: &str, bytes: &[u8]) -> Result<()> { + let dir = dest.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(dir).with_context(|| format!("mkdir {}", dir.display()))?; + let mut staging = staging_file_in(dir, prefix)?; + staging + .write_all(bytes) + .with_context(|| format!("write staging {}", staging.path().display()))?; + staging + .persist(dest) + .map_err(|err| err.error) + .with_context(|| format!("rename staging file -> {}", dest.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{replace_file, staging_dir_in, staging_file_in}; + + #[test] + fn replace_file_writes_a_regular_file_and_leaves_no_staging_sibling() { + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("out.txt"); + replace_file(&dest, ".out.txt.tmp-", b"first\n").unwrap(); + replace_file(&dest, ".out.txt.tmp-", b"second\n").unwrap(); + assert_eq!(std::fs::read_to_string(&dest).unwrap(), "second\n"); + let leftovers: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|name| name.starts_with(".out.txt.tmp-")) + .collect(); + assert!( + leftovers.is_empty(), + "staging sibling leaked: {leftovers:?}" + ); + } + + #[cfg(unix)] + #[test] + fn replace_file_never_follows_a_planted_symlink() { + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("out.txt"); + let victim = dir.path().join("victim"); + std::fs::write(&victim, "keep\n").unwrap(); + // The name a PID-derived scheme would have used, and the destination + // itself, both pre-planted as links to the victim. + let planted = dir + .path() + .join(format!(".out.txt.tmp-{}", std::process::id())); + symlink(&victim, &planted).unwrap(); + symlink(&victim, &dest).unwrap(); + + replace_file(&dest, ".out.txt.tmp-", b"payload\n").unwrap(); + + assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep\n"); + assert!( + !std::fs::symlink_metadata(&dest) + .unwrap() + .file_type() + .is_symlink(), + "the destination must be replaced by a regular file, not written through" + ); + assert_eq!(std::fs::read_to_string(&dest).unwrap(), "payload\n"); + assert!( + std::fs::symlink_metadata(&planted) + .unwrap() + .file_type() + .is_symlink() + ); + } + + #[cfg(unix)] + #[test] + fn staging_file_honours_umask_like_fs_write() { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempfile::tempdir().unwrap(); + let control = dir.path().join("control"); + std::fs::write(&control, b"x").unwrap(); + let staged = staging_file_in(dir.path(), ".probe-").unwrap(); + let expect = std::fs::metadata(&control).unwrap().permissions().mode() & 0o777; + let got = staged.as_file().metadata().unwrap().permissions().mode() & 0o777; + assert_eq!(got, expect, "staging mode must match what fs::write yields"); + } + + #[test] + fn staging_dir_is_created_exclusively_in_the_requested_parent() { + let dir = tempfile::tempdir().unwrap(); + let staged = staging_dir_in(dir.path(), ".pack.tmp-").unwrap(); + assert_eq!(staged.path().parent(), Some(dir.path())); + assert!(staged.path().is_dir()); + assert!( + staged + .path() + .file_name() + .unwrap() + .to_string_lossy() + .starts_with(".pack.tmp-") + ); + } +} diff --git a/crates/loomweave-cli/src/db.rs b/crates/loomweave-cli/src/db.rs index 072676f4..6435d481 100644 --- a/crates/loomweave-cli/src/db.rs +++ b/crates/loomweave-cli/src/db.rs @@ -14,6 +14,7 @@ use std::path::Path; use std::time::Duration; +use crate::atomic_fs::staging_file_in; use anyhow::{Context, Result, anyhow, bail, ensure}; use rusqlite::{Connection, OpenFlags}; @@ -74,37 +75,25 @@ pub fn backup(project_root: &Path, output: &Path, force: bool) -> Result<()> { // Stage into a sibling temp file so a crash mid-copy can never leave a // truncated file sitting at `output`. Renaming is atomic on the same // filesystem; staging as a sibling keeps us on it. - let parent = output.parent().filter(|p| !p.as_os_str().is_empty()); - if let Some(parent) = parent { - std::fs::create_dir_all(parent) - .with_context(|| format!("create backup output directory {}", parent.display()))?; - } - let staging = staging_path(output); - // Clear any stale staging file from a previous interrupted run. - if staging.exists() { - std::fs::remove_file(&staging) - .with_context(|| format!("clear stale staging file {}", staging.display()))?; - } - - let result = run_backup(&db_path, &staging); - match result { - Ok(()) => { - std::fs::rename(&staging, output).with_context(|| { - format!( - "rename backup {} -> {}", - staging.display(), - output.display() - ) - })?; - println!("Backed up {} -> {}", db_path.display(), output.display()); - Ok(()) - } - Err(err) => { - // Best-effort cleanup so a failed run leaves no debris behind. - let _ = std::fs::remove_file(&staging); - Err(err) - } - } + let parent = output + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent) + .with_context(|| format!("create backup output directory {}", parent.display()))?; + // Exclusive staging (see `atomic_fs`): SQLite opens the path it is given, + // so a guessable sibling name pre-planted as a symlink would have turned + // the backup into a write-through. The `TempPath` unlinks the staging file + // on every failure path, so an interrupted run leaves no debris. + let staging = staging_file_in(parent, &staging_prefix(output))?.into_temp_path(); + + run_backup(&db_path, &staging)?; + staging + .persist(output) + .map_err(|err| err.error) + .with_context(|| format!("rename backup staging -> {}", output.display()))?; + println!("Backed up {} -> {}", db_path.display(), output.display()); + Ok(()) } /// Force a `PRAGMA wal_checkpoint(TRUNCATE)` on the working store so the on-disk @@ -183,11 +172,12 @@ fn run_backup(db_path: &Path, staging: &Path) -> Result<()> { Ok(()) } -/// Sibling staging path for the atomic write (`.loomweave-backup.tmp-`). -fn staging_path(output: &Path) -> std::path::PathBuf { - let mut name = output.as_os_str().to_os_string(); - name.push(format!(".loomweave-backup.tmp-{}", std::process::id())); - std::path::PathBuf::from(name) +/// Prefix for the sibling staging file (`.loomweave-backup.tmp-`). +fn staging_prefix(output: &Path) -> String { + let name = output + .file_name() + .map_or_else(|| "backup".to_owned(), |n| n.to_string_lossy().into_owned()); + format!("{name}.loomweave-backup.tmp-") } /// True if both paths denote the same on-disk file. Falls back to a lexical @@ -198,3 +188,56 @@ fn paths_are_same(a: &Path, b: &Path) -> bool { _ => a == b, } } + +#[cfg(test)] +mod tests { + #[cfg(unix)] + #[test] + fn backup_never_follows_a_planted_staging_symlink() { + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("project"); + std::fs::create_dir_all(&project).unwrap(); + // A plain (non-git) project root resolves to the default store leaf. + // Spelled out so the worktree store-path audit does not read this + // test as an unclassified runtime resolution site. + let live = project.join(".weft").join("loomweave").join("loomweave.db"); + std::fs::create_dir_all(live.parent().unwrap()).unwrap(); + let conn = rusqlite::Connection::open(&live).unwrap(); + conn.execute_batch("CREATE TABLE t(x INTEGER); INSERT INTO t VALUES (42);") + .unwrap(); + drop(conn); + + let out_dir = dir.path().join("backups"); + std::fs::create_dir_all(&out_dir).unwrap(); + let output = out_dir.join("snap.db"); + let victim = dir.path().join("victim"); + std::fs::write(&victim, "keep\n").unwrap(); + // The staging name the PID-derived scheme used. SQLite opens whatever + // path it is handed, so a symlink here was a write-through. + let planted = out_dir.join(format!( + "snap.db.loomweave-backup.tmp-{}", + std::process::id() + )); + symlink(&victim, &planted).unwrap(); + + super::backup(&project, &output, false).unwrap(); + + assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep\n"); + assert!( + !std::fs::symlink_metadata(&output) + .unwrap() + .file_type() + .is_symlink() + ); + let conn = rusqlite::Connection::open(&output).unwrap(); + let x: i64 = conn.query_row("SELECT x FROM t", [], |r| r.get(0)).unwrap(); + assert_eq!(x, 42); + assert!( + std::fs::symlink_metadata(&planted) + .unwrap() + .file_type() + .is_symlink() + ); + } +} diff --git a/crates/loomweave-cli/src/hooks_settings.rs b/crates/loomweave-cli/src/hooks_settings.rs index 3b10e3a9..76cbc1c0 100644 --- a/crates/loomweave-cli/src/hooks_settings.rs +++ b/crates/loomweave-cli/src/hooks_settings.rs @@ -334,24 +334,20 @@ pub fn install_session_start_hook(project_root: &Path) -> Result { let serialized = serde_json::to_string_pretty(&settings).context("serialize .claude/settings.json")?; - // Atomic write: stage into a sibling temp file in the same directory, then - // rename over the destination (same-filesystem atomic swap). This protects - // the user's hand-authored settings.json from truncation/corruption on a - // crash or concurrent install mid-write. Mirrors skill_pack::stage_and_swap. - let tmp = claude_dir.join(format!(".settings.json.tmp-{}", std::process::id())); - if let Err(err) = write_and_swap(&tmp, &settings_path, &serialized) { - let _ = fs::remove_file(&tmp); - return Err(err); - } + // Atomic write: create an unpredictable sibling exclusively, then rename + // it over the destination (same-filesystem atomic swap). Exclusive creation + // prevents a repository-controlled symlink from redirecting the write. + write_and_swap(&claude_dir, &settings_path, &serialized)?; Ok(true) } -fn write_and_swap(tmp: &Path, dest: &Path, serialized: &str) -> Result<()> { - fs::write(tmp, format!("{serialized}\n")) - .with_context(|| format!("write staging {}", tmp.display()))?; - fs::rename(tmp, dest) - .with_context(|| format!("rename {} -> {}", tmp.display(), dest.display()))?; - Ok(()) +fn write_and_swap(dir: &Path, dest: &Path, serialized: &str) -> Result<()> { + debug_assert_eq!(dest.parent(), Some(dir)); + crate::atomic_fs::replace_file( + dest, + ".settings.json.tmp-", + format!("{serialized}\n").as_bytes(), + ) } #[cfg(test)] @@ -880,4 +876,34 @@ mod tests { "a failed install must leave the user's settings.json byte-for-byte intact" ); } + + #[cfg(unix)] + #[test] + fn install_does_not_follow_predictable_staging_symlink() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let claude_dir = dir.path().join(".claude"); + fs::create_dir(&claude_dir).unwrap(); + let outside = dir.path().join("outside.json"); + fs::write(&outside, "outside must remain unchanged\n").unwrap(); + + // This was the staging name before randomized, exclusive temp-file + // creation. A malicious repository could commit such a symlink. + let attacker_link = claude_dir.join(format!(".settings.json.tmp-{}", std::process::id())); + symlink(&outside, &attacker_link).unwrap(); + + assert!(install_session_start_hook(dir.path()).unwrap()); + assert_eq!( + fs::read_to_string(&outside).unwrap(), + "outside must remain unchanged\n" + ); + assert!( + !fs::symlink_metadata(claude_dir.join("settings.json")) + .unwrap() + .file_type() + .is_symlink(), + "installed settings must be a regular file, not the attacker symlink" + ); + } } diff --git a/crates/loomweave-cli/src/install.rs b/crates/loomweave-cli/src/install.rs index e4f46d9c..2d5148d9 100644 --- a/crates/loomweave-cli/src/install.rs +++ b/crates/loomweave-cli/src/install.rs @@ -628,18 +628,11 @@ fn populate_after_mkdir(loomweave_dir: &Path, project_root: &Path) -> Result<()> pub(crate) fn write_gitignore(store_dir: &Path) -> Result<()> { fs::create_dir_all(store_dir).with_context(|| format!("mkdir {}", store_dir.display()))?; let target = store_dir.join(".gitignore"); - let temp = store_dir.join(format!(".gitignore.loomweave.tmp-{}", std::process::id())); - if let Err(err) = fs::write(&temp, GITIGNORE_CONTENTS) - .with_context(|| format!("write {}", temp.display())) - .and_then(|()| { - fs::rename(&temp, &target) - .with_context(|| format!("rename {} -> {}", temp.display(), target.display())) - }) - { - let _ = fs::remove_file(&temp); - return Err(err); - } - Ok(()) + crate::atomic_fs::replace_file( + &target, + ".gitignore.loomweave.tmp-", + GITIGNORE_CONTENTS.as_bytes(), + ) } fn initialise_db(path: &Path) -> Result<()> { @@ -654,6 +647,35 @@ fn initialise_db(path: &Path) -> Result<()> { mod tests { use super::{GITIGNORE_CONTENTS, InstallComponent, InstallPlan}; + #[cfg(unix)] + #[test] + fn write_gitignore_never_follows_a_planted_staging_symlink() { + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().unwrap(); + let store = dir.path().join(".weft").join("loomweave"); + std::fs::create_dir_all(&store).unwrap(); + let victim = dir.path().join("victim"); + std::fs::write(&victim, "keep\n").unwrap(); + // The staging name the PID-derived scheme used; a repository can + // commit this symlink under `.weft/loomweave/`. + let planted = store.join(format!(".gitignore.loomweave.tmp-{}", std::process::id())); + symlink(&victim, &planted).unwrap(); + + super::write_gitignore(&store).unwrap(); + + assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep\n"); + assert_eq!( + std::fs::read_to_string(store.join(".gitignore")).unwrap(), + GITIGNORE_CONTENTS + ); + assert!( + std::fs::symlink_metadata(&planted) + .unwrap() + .file_type() + .is_symlink() + ); + } + #[test] fn canonical_gitignore_excludes_the_isolated_worktree_namespace() { assert!( diff --git a/crates/loomweave-cli/src/instructions.rs b/crates/loomweave-cli/src/instructions.rs index ae19787c..a660e880 100644 --- a/crates/loomweave-cli/src/instructions.rs +++ b/crates/loomweave-cli/src/instructions.rs @@ -29,6 +29,7 @@ //! provenance only. use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; @@ -986,26 +987,18 @@ fn atomic_write(path: &Path, content: &str) -> Result<()> { || "instructions".to_owned(), |n| n.to_string_lossy().into_owned(), ); - let temp_path: PathBuf = parent.join(format!( - ".{}.loomweave.tmp-{}", - file_name, - std::process::id() - )); - - // Cleanup guard: drop the staged temp file if any step after creating it - // fails, so a failed write never leaks a `.tmp-*` sibling. - if let Err(err) = write_temp_then_rename(&temp_path, path, content) { - let _ = fs::remove_file(&temp_path); - return Err(err); - } - Ok(()) -} - -fn write_temp_then_rename(temp_path: &Path, path: &Path, content: &str) -> Result<()> { - fs::write(temp_path, content).with_context(|| format!("write {}", temp_path.display()))?; + // Exclusive, unpredictable staging (see `atomic_fs`): a pre-planted symlink + // at a guessable sibling name can never be written through, and the final + // persist stays a same-filesystem atomic rename. + let mut temp = + crate::atomic_fs::staging_file_in(parent, &format!(".{file_name}.loomweave.tmp-"))?; + temp.write_all(content.as_bytes()) + .with_context(|| format!("write {}", temp.path().display()))?; #[cfg(unix)] - preserve_mode(path, temp_path)?; - fs::rename(temp_path, path) + preserve_mode(path, temp.path())?; + let temp_path = temp.path().to_path_buf(); + temp.persist(path) + .map_err(|err| err.error) .with_context(|| format!("rename {} -> {}", temp_path.display(), path.display()))?; Ok(()) } @@ -1549,6 +1542,41 @@ filigree tracks tasks for this project.\n\ ); } + #[cfg(unix)] + #[test] + fn atomic_write_does_not_follow_predictable_temp_symlink() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("CLAUDE.md"); + let victim = dir.path().join("victim"); + std::fs::write(&victim, "do not overwrite\n").unwrap(); + + // This is the deterministic staging name used by the vulnerable + // implementation. A repository could commit such a link, or another + // local process could create it after learning the installer's PID. + let planted = dir + .path() + .join(format!(".CLAUDE.md.loomweave.tmp-{}", std::process::id())); + symlink(&victim, &planted).unwrap(); + + super::atomic_write(&path, "safe contents\n").unwrap(); + + assert_eq!(std::fs::read_to_string(&path).unwrap(), "safe contents\n"); + assert_eq!( + std::fs::read_to_string(&victim).unwrap(), + "do not overwrite\n", + "the planted temp symlink must never be followed" + ); + assert!( + std::fs::symlink_metadata(&planted) + .unwrap() + .file_type() + .is_symlink(), + "an unrelated planted path must not be renamed over the target" + ); + } + #[cfg(unix)] #[test] fn atomic_write_preserves_mode() { diff --git a/crates/loomweave-cli/src/main.rs b/crates/loomweave-cli/src/main.rs index 96a23ee9..b3f5dfd2 100644 --- a/crates/loomweave-cli/src/main.rs +++ b/crates/loomweave-cli/src/main.rs @@ -1,5 +1,6 @@ mod analyze; mod analyze_lock; +mod atomic_fs; mod cli; mod config; mod db; diff --git a/crates/loomweave-cli/src/mcp_registration.rs b/crates/loomweave-cli/src/mcp_registration.rs index 07059268..3917aecf 100644 --- a/crates/loomweave-cli/src/mcp_registration.rs +++ b/crates/loomweave-cli/src/mcp_registration.rs @@ -263,14 +263,10 @@ pub fn install_mcp_entry(project_root: &Path) -> Result { } let serialized = serde_json::to_string_pretty(&root).context("serialize .mcp.json")?; - // Atomic write: stage a sibling temp file in the project root (same + // Atomic write: exclusive sibling staging in the project root (same // filesystem), then rename over the destination. Mirrors // hooks_settings::write_and_swap. - let tmp = project_root.join(format!(".mcp.json.tmp-{}", std::process::id())); - if let Err(err) = write_and_swap(&tmp, &path, &serialized) { - let _ = fs::remove_file(&tmp); - return Err(err); - } + write_and_swap(&path, &serialized)?; Ok(true) } @@ -396,12 +392,8 @@ fn write_text_if_changed(path: &Path, content: &str) -> Result { Ok(true) } -fn write_and_swap(tmp: &Path, dest: &Path, serialized: &str) -> Result<()> { - fs::write(tmp, format!("{serialized}\n")) - .with_context(|| format!("write staging {}", tmp.display()))?; - fs::rename(tmp, dest) - .with_context(|| format!("rename {} -> {}", tmp.display(), dest.display()))?; - Ok(()) +fn write_and_swap(dest: &Path, serialized: &str) -> Result<()> { + crate::atomic_fs::replace_file(dest, ".mcp.json.tmp-", format!("{serialized}\n").as_bytes()) } #[cfg(test)] @@ -412,6 +404,40 @@ mod tests { use super::{McpState, install_mcp_entry, mcp_entry_state}; + #[cfg(unix)] + #[test] + fn install_never_follows_a_planted_staging_symlink() { + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().unwrap(); + let victim = dir.path().join("victim.json"); + fs::write(&victim, "{\"keep\":true}\n").unwrap(); + // The staging name the PID-derived scheme used, committed at the + // project root by a hostile repository. + let planted = dir + .path() + .join(format!(".mcp.json.tmp-{}", std::process::id())); + symlink(&victim, &planted).unwrap(); + + assert!(install_mcp_entry(dir.path()).unwrap()); + + assert_eq!(fs::read_to_string(&victim).unwrap(), "{\"keep\":true}\n"); + let written = dir.path().join(".mcp.json"); + assert!( + !fs::symlink_metadata(&written) + .unwrap() + .file_type() + .is_symlink() + ); + let value: Value = serde_json::from_str(&fs::read_to_string(&written).unwrap()).unwrap(); + assert!(value["mcpServers"]["loomweave"].is_object()); + assert!( + fs::symlink_metadata(&planted) + .unwrap() + .file_type() + .is_symlink() + ); + } + #[test] fn state_missing_then_present_around_install() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/loomweave-cli/src/skill_pack.rs b/crates/loomweave-cli/src/skill_pack.rs index e4bff8df..5ed33e94 100644 --- a/crates/loomweave-cli/src/skill_pack.rs +++ b/crates/loomweave-cli/src/skill_pack.rs @@ -165,22 +165,14 @@ pub fn skill_pack_state(project_root: &Path) -> SkillPackState { fn stage_and_swap(root: &Path, dest: &Path, fingerprint: &str) -> Result<()> { fs::create_dir_all(root).with_context(|| format!("mkdir {}", root.display()))?; - // Stage in a sibling temp dir so the final rename is same-filesystem. - let staging = root.join(format!(".loomweave-workflow.tmp-{}", std::process::id())); - if staging.exists() { - fs::remove_dir_all(&staging) - .with_context(|| format!("clear stale staging {}", staging.display()))?; - } - fs::create_dir_all(&staging).with_context(|| format!("mkdir {}", staging.display()))?; - - // Cleanup guard: if writing the staged files fails, remove the staging dir - // before bubbling the error so we don't leak a `.loomweave-workflow.tmp-*` - // sibling. Matches the partial-state-cleanup precedent on the `.weft/loomweave/` - // path in install.rs. The original error is preserved. - if let Err(err) = write_staged_pack(&staging, fingerprint) { - let _ = fs::remove_dir_all(&staging); - return Err(err); - } + // Stage in an exclusive, unpredictably named sibling dir (see `atomic_fs`) + // so the final rename is same-filesystem and a pre-planted entry at a + // guessable name is never reused. The `TempDir` removes the staging tree + // on every early-return path, so a failed write never leaks a + // `.loomweave-workflow.tmp-*` sibling. + let staging_dir = crate::atomic_fs::staging_dir_in(root, ".loomweave-workflow.tmp-")?; + let staging = staging_dir.path().to_path_buf(); + write_staged_pack(&staging, fingerprint)?; // Crash-safe swap: move the existing pack aside, rename the staged pack // into place, then drop the backup. On failure, restore the backup so the @@ -191,12 +183,12 @@ fn stage_and_swap(root: &Path, dest: &Path, fingerprint: &str) -> Result<()> { // backup, so the previously-installed pack is always recoverable; we never // delete `dest` ahead of a rename that might not happen. let had_existing = dest.exists(); - let backup = root.join(format!(".loomweave-workflow.bak-{}", std::process::id())); + // The backup slot is an exclusive empty directory: `rename(2)` replaces an + // empty directory atomically, and a planted symlink at a guessable name + // can neither be followed nor swapped into place. + let backup_dir = crate::atomic_fs::staging_dir_in(root, ".loomweave-workflow.bak-")?; + let backup = backup_dir.path().to_path_buf(); if had_existing { - if backup.exists() { - fs::remove_dir_all(&backup) - .with_context(|| format!("clear stale backup {}", backup.display()))?; - } fs::rename(dest, &backup).with_context(|| { format!( "back up existing {} -> {}", @@ -207,17 +199,18 @@ fn stage_and_swap(root: &Path, dest: &Path, fingerprint: &str) -> Result<()> { } match fs::rename(&staging, dest) { Ok(()) => { - if had_existing { - let _ = fs::remove_dir_all(&backup); - } + // The staged tree now lives at `dest`; detach the guard so its + // drop does not chase the moved path. `backup_dir` drops here and + // removes the superseded pack. + let _ = staging_dir.keep(); Ok(()) } Err(err) => { - // Restore the previous pack so orientation is never left broken. + // Restore the previous pack so orientation is never left broken; + // `staging_dir` / `backup_dir` drop and clear whatever is left. if had_existing { let _ = fs::rename(&backup, dest); } - let _ = fs::remove_dir_all(&staging); Err(anyhow::Error::new(err)) .with_context(|| format!("swap staged pack into {}", dest.display())) } diff --git a/crates/loomweave-federation/Cargo.toml b/crates/loomweave-federation/Cargo.toml index 5f49325f..5f574211 100644 --- a/crates/loomweave-federation/Cargo.toml +++ b/crates/loomweave-federation/Cargo.toml @@ -11,6 +11,7 @@ rust-version.workspace = true workspace = true [dependencies] +tempfile.workspace = true blake3.workspace = true loomweave-core = { path = "../loomweave-core", version = "1.6.0" } reqwest.workspace = true @@ -20,4 +21,3 @@ serde_norway.workspace = true thiserror.workspace = true [dev-dependencies] -tempfile.workspace = true diff --git a/crates/loomweave-federation/src/loomweave_port.rs b/crates/loomweave-federation/src/loomweave_port.rs index 16501f20..6b6c430f 100644 --- a/crates/loomweave-federation/src/loomweave_port.rs +++ b/crates/loomweave-federation/src/loomweave_port.rs @@ -113,15 +113,27 @@ pub fn publish_port_at(port_path: &Path, port: u16) -> std::io::Result<()> { )); }; std::fs::create_dir_all(dir)?; - // One `serve` per process publishes, so the PID makes the temp name unique - // within this directory without needing a random suffix. - let tmp = dir.join(format!("ephemeral.port.{}.tmp", std::process::id())); - std::fs::write(&tmp, format!("{port}\n"))?; - if let Err(err) = std::fs::rename(&tmp, port_path) { - // A successful write + failed rename would otherwise strand the temp. - let _ = std::fs::remove_file(&tmp); - return Err(err); + // Exclusive, unpredictable staging: `.weft/loomweave/` is inside the + // analyzed checkout, so a guessable sibling name (the old + // `ephemeral.port..tmp`) pre-planted as a symlink would have turned + // this write into a write-through. `O_EXCL` on a random name means the + // staging file is ours or the publish fails; the `NamedTempFile` unlinks + // it on every failure path, so a failed persist strands nothing. + let mut builder = tempfile::Builder::new(); + builder.prefix("ephemeral.port.").suffix(".tmp"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + // Match what `fs::write` produced (0o666 & ~umask): siblings resolve + // the port by reading this file. + builder.permissions(std::fs::Permissions::from_mode(0o666)); } + let mut tmp = builder.tempfile_in(dir)?; + { + use std::io::Write as _; + tmp.write_all(format!("{port}\n").as_bytes())?; + } + tmp.persist(port_path).map_err(|err| err.error)?; Ok(()) } @@ -170,6 +182,35 @@ pub fn remove_published_port_if_matches_at(port_path: &Path, port: u16) { mod tests { use super::*; + #[cfg(unix)] + #[test] + fn publish_port_never_follows_a_planted_staging_symlink() { + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().unwrap(); + let victim = dir.path().join("victim"); + std::fs::write(&victim, "keep\n").unwrap(); + let port_path = dir.path().join("store").join("ephemeral.port"); + std::fs::create_dir_all(port_path.parent().unwrap()).unwrap(); + // The staging name the PID-derived scheme used; `.weft/loomweave/` is + // inside the checkout, so a repository can commit this symlink. + let planted = port_path + .parent() + .unwrap() + .join(format!("ephemeral.port.{}.tmp", std::process::id())); + symlink(&victim, &planted).unwrap(); + + publish_port_at(&port_path, 4321).unwrap(); + + assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep\n"); + assert_eq!(std::fs::read_to_string(&port_path).unwrap(), "4321\n"); + assert!( + std::fs::symlink_metadata(&planted) + .unwrap() + .file_type() + .is_symlink() + ); + } + #[test] fn deterministic_port_is_stable_and_in_band() { let dir = tempfile::tempdir().unwrap();