From 10507f1909292416a6c3770e8542bc99d480ad73 Mon Sep 17 00:00:00 2001 From: factory-ain3sh Date: Fri, 24 Jul 2026 03:37:38 -0700 Subject: [PATCH] feat(cli): add portable session seeding Capture dirty worktree and local commit state before mounting so pinned sessions retain the sender's merged view across machines. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- crates/vfs-cli/src/cmd/mod.rs | 1 + crates/vfs-cli/src/cmd/pack.rs | 8 +- crates/vfs-cli/src/cmd/run/darwin.rs | 36 +- crates/vfs-cli/src/cmd/run/linux.rs | 35 +- crates/vfs-cli/src/cmd/seed.rs | 1398 +++++++++++++++++++ crates/vfs-cli/src/cmd/session_lock.rs | 59 +- crates/vfs-cli/src/cmd/version.rs | 4 +- crates/vfs-cli/src/main.rs | 15 + crates/vfs-cli/src/opts.rs | 48 + crates/vfs-core/src/fs/overlay/mod.rs | 12 + crates/vfs-core/src/fs/overlay/whiteouts.rs | 12 - crates/vfs-core/src/session.rs | 54 + docs/MANUAL.md | 54 + 13 files changed, 1700 insertions(+), 36 deletions(-) create mode 100644 crates/vfs-cli/src/cmd/seed.rs diff --git a/crates/vfs-cli/src/cmd/mod.rs b/crates/vfs-cli/src/cmd/mod.rs index e42c736f..b069c7a2 100644 --- a/crates/vfs-cli/src/cmd/mod.rs +++ b/crates/vfs-cli/src/cmd/mod.rs @@ -7,6 +7,7 @@ pub mod pack; pub mod profiling; pub mod ps; pub mod safety; +pub mod seed; mod session_lock; pub mod sync; pub mod timeline; diff --git a/crates/vfs-cli/src/cmd/pack.rs b/crates/vfs-cli/src/cmd/pack.rs index c992089d..d989db9f 100644 --- a/crates/vfs-cli/src/cmd/pack.rs +++ b/crates/vfs-cli/src/cmd/pack.rs @@ -335,7 +335,7 @@ fn validate_output_target(output: Option<&Path>, live_db: &Path) -> Result<()> { Ok(()) } -fn copy_database_family(source: &Path, target: &Path) -> Result<()> { +pub(crate) fn copy_database_family(source: &Path, target: &Path) -> Result<()> { copy_file_exclusive(source, target)?; for suffix in ["-wal", "-shm"] { let source_sidecar = sidecar_path(source, suffix); @@ -500,7 +500,7 @@ fn rollback_live_database(live: &Path, backup: &Path) -> Result<()> { .context("Failed to roll back the live session database after output publication failed") } -fn rename_database_family(source: &Path, target: &Path) -> Result<()> { +pub(crate) fn rename_database_family(source: &Path, target: &Path) -> Result<()> { let mut renamed = Vec::new(); for suffix in ["", "-wal", "-shm"] { let source_path = if suffix.is_empty() { @@ -546,7 +546,7 @@ fn cleanup_backup_family(backup: &Path) { } } -fn remove_database_family(path: &Path) { +pub(crate) fn remove_database_family(path: &Path) { for path in database_family(path) { let _ = fs::remove_file(path); } @@ -560,7 +560,7 @@ fn database_family(path: &Path) -> [PathBuf; 3] { ] } -fn sync_file_and_parent(path: &Path) -> Result<()> { +pub(crate) fn sync_file_and_parent(path: &Path) -> Result<()> { fs::OpenOptions::new() .read(true) .write(true) diff --git a/crates/vfs-cli/src/cmd/run/darwin.rs b/crates/vfs-cli/src/cmd/run/darwin.rs index d6862893..c90f467e 100644 --- a/crates/vfs-cli/src/cmd/run/darwin.rs +++ b/crates/vfs-cli/src/cmd/run/darwin.rs @@ -301,13 +301,33 @@ pub async fn run(options: RunOptions) -> Result<()> { system: _, encryption, partial_origin_policy, + seed_pin, command, args, } = options; let cwd = std::env::current_dir().context("Failed to get current directory")?; let home = dirs::home_dir().context("Failed to get home directory")?; - let session = setup_run_directory(session, allow, no_default_allows, &cwd, &home)?; + let mut session = setup_run_directory( + session, + allow, + no_default_allows, + &cwd, + &home, + seed_pin.is_none(), + )?; + if let Some(pin) = seed_pin { + let seeded = crate::cmd::seed::seed_session( + &home, + &session.session_id, + &pin, + encryption.clone(), + true, + Some(&cwd), + ) + .await?; + session._session_lock = Some(seeded.into_shared_lock()?); + } // Check if we're joining an existing session if is_mountpoint(&session.mountpoint) { @@ -469,7 +489,7 @@ fn print_welcome_banner(session: &RunSession, encrypted: bool) { /// Configuration for a sandbox run session. struct RunSession { /// Shared advisory lock preventing pack from publishing over this run. - _session_lock: crate::cmd::session_lock::SessionLock, + _session_lock: Option, /// Directory containing session artifacts. run_dir: PathBuf, /// Path to the delta database. @@ -494,12 +514,20 @@ fn setup_run_directory( no_default_allows: bool, cwd: &Path, home: &Path, + acquire_lock: bool, ) -> Result { let run_id = session_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); let run_dir = home.join(".vfs").join("run").join(&run_id); std::fs::create_dir_all(&run_dir).context("Failed to create run directory")?; - let session_lock = crate::cmd::session_lock::SessionLock::try_shared(&run_dir) - .with_context(|| format!("session {run_id} is being packed; retry after it finishes"))?; + let session_lock = if acquire_lock { + Some( + crate::cmd::session_lock::SessionLock::try_shared(&run_dir).with_context(|| { + format!("session {run_id} is being packed or seeded; retry after it finishes") + })?, + ) + } else { + None + }; let db_path = run_dir.join("delta.db"); let mountpoint = run_dir.join("mnt"); diff --git a/crates/vfs-cli/src/cmd/run/linux.rs b/crates/vfs-cli/src/cmd/run/linux.rs index 251422f9..58fa6e0d 100644 --- a/crates/vfs-cli/src/cmd/run/linux.rs +++ b/crates/vfs-cli/src/cmd/run/linux.rs @@ -75,6 +75,7 @@ pub fn run(options: RunOptions) -> Result<()> { system, encryption, partial_origin_policy, + seed_pin, command, args, } = options; @@ -85,7 +86,24 @@ pub fn run(options: RunOptions) -> Result<()> { let allowed_paths = build_allowed_paths(&allow, no_default_allows)?; // Check if we're joining an existing session - let session = setup_run_directory(session)?; + let mut session = setup_run_directory(session, seed_pin.is_none())?; + if let Some(pin) = seed_pin { + let home = dirs::home_dir().context("Failed to get home directory")?; + let seed_runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("Failed to create seed runtime")?; + let seeded = seed_runtime.block_on(crate::cmd::seed::seed_session( + &home, + &session.run_id, + &pin, + encryption.clone(), + true, + Some(&cwd), + ))?; + drop(seed_runtime); + session._session_lock = Some(seeded.into_shared_lock()?); + } // If the FUSE mountpoint is already mounted, join the existing session if is_mountpoint(&session.fuse_mountpoint) { @@ -489,7 +507,7 @@ fn print_welcome_banner(cwd: &Path, allowed_paths: &[PathBuf], session_id: &str, /// Configuration for a sandbox run session. struct RunSession { /// Shared advisory lock preventing pack from publishing over this run. - _session_lock: crate::cmd::session_lock::SessionLock, + _session_lock: Option, /// Unique identifier for this run. run_id: String, /// Path to the delta database. @@ -504,13 +522,20 @@ struct RunSession { /// /// If `session_id` is provided, uses that as the run ID (allowing multiple /// runs to share the same delta layer). Otherwise generates a unique UUID. -fn setup_run_directory(session_id: Option) -> Result { +fn setup_run_directory(session_id: Option, acquire_lock: bool) -> Result { let run_id = session_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); let home_dir = dirs::home_dir().context("Failed to get home directory")?; let run_dir = home_dir.join(".vfs").join("run").join(&run_id); std::fs::create_dir_all(&run_dir).context("Failed to create run directory")?; - let session_lock = crate::cmd::session_lock::SessionLock::try_shared(&run_dir) - .with_context(|| format!("session {run_id} is being packed; retry after it finishes"))?; + let session_lock = if acquire_lock { + Some( + crate::cmd::session_lock::SessionLock::try_shared(&run_dir).with_context(|| { + format!("session {run_id} is being packed or seeded; retry after it finishes") + })?, + ) + } else { + None + }; let db_path = run_dir.join("delta.db"); let fuse_mountpoint = run_dir.join("mnt"); diff --git a/crates/vfs-cli/src/cmd/seed.rs b/crates/vfs-cli/src/cmd/seed.rs new file mode 100644 index 00000000..ef37facf --- /dev/null +++ b/crates/vfs-cli/src/cmd/seed.rs @@ -0,0 +1,1398 @@ +//! Birth-time capture of git state into a portable run-session delta. + +use std::collections::BTreeSet; +use std::fs; +use std::io::Write; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::MetadataExt; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{bail, Context, Result}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use vfs_core::{ + EncryptionConfig, ImportEntry, ImportOptions, OverlayFS, Vfs, VfsOptions, S_IFDIR, S_IFREG, +}; + +use super::pack::SessionStillRunning; + +const ROOT_INO: i64 = 1; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SeedSummary { + seeded_paths: Vec, + whiteout_paths: Vec, + local_commits: u64, + pin: String, +} + +pub(crate) struct SeededSession { + pub(crate) summary: SeedSummary, + session_lock: super::session_lock::SessionLock, +} + +impl std::fmt::Debug for SeededSession { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SeededSession") + .field("summary", &self.summary) + .finish_non_exhaustive() + } +} + +impl SeededSession { + pub(crate) fn into_shared_lock(self) -> Result { + self.session_lock + .downgrade_to_shared() + .context("Failed to downgrade the seed lock for session startup") + } +} + +/// Seed an inactive run session and print its machine-readable summary. +pub async fn handle_seed_command( + stdout: &mut impl Write, + session_id: String, + pin: String, + _json: bool, +) -> Result<()> { + let home = dirs::home_dir().context("Failed to get home directory")?; + let seeded = seed_session(&home, &session_id, &pin, None, false, None).await?; + serde_json::to_writer(&mut *stdout, &seeded.summary)?; + writeln!(stdout)?; + Ok(()) +} + +/// Seed a session before `vfs run` acquires its lifetime shared lock. +pub(crate) async fn seed_session( + home: &Path, + session_id: &str, + pin: &str, + encryption: Option, + create_database: bool, + requested_base_path: Option<&Path>, +) -> Result { + if !VfsOptions::validate_agent_id(session_id) { + bail!("invalid session ID: {session_id}"); + } + + let session_dir = home.join(".vfs").join("run").join(session_id); + if !session_dir.is_dir() { + bail!("session not found: {}", session_dir.display()); + } + let session_lock = + super::session_lock::SessionLock::try_exclusive(&session_dir).map_err(|error| { + if error.kind() == std::io::ErrorKind::WouldBlock { + anyhow::Error::new(SessionStillRunning) + } else { + anyhow::Error::new(error).context("Failed to lock session for seeding") + } + })?; + ensure_session_inactive(&session_dir)?; + + let (base_path, publish_base_path) = resolve_base_path(&session_dir, requested_base_path)?; + let db_path = session_dir.join("delta.db"); + recover_interrupted_publication(&db_path)?; + if !db_path.is_file() && !create_database { + bail!("session database not found: {}", db_path.display()); + } + let staging = StagedDatabase::new( + session_dir.join(format!(".delta.db.seed-{}.tmp", uuid::Uuid::new_v4())), + ); + if db_path.is_file() { + super::pack::copy_database_family(&db_path, staging.path()) + .context("Failed to stage the session database for seeding")?; + } + + let mut options = VfsOptions::with_path(staging.path().to_string_lossy()) + .with_core_config(crate::config::core_config_from_env()); + if let Some(encryption) = encryption { + options = options.with_encryption(encryption); + } + let vfs = Vfs::open(options) + .await + .map_err(|error| super::migrate::open_error_with_guidance(error, session_id)) + .context("Failed to open session database for seeding")?; + if !vfs.session_metadata().await?.seeded_paths.is_empty() { + super::init::finalize_readonly(&vfs).await; + bail!("session already seeded"); + } + + eprintln!("Inspecting git state in {}...", base_path.display()); + let plan = match build_seed_plan(&base_path, pin) { + Ok(plan) => plan, + Err(error) => { + super::init::finalize_readonly(&vfs).await; + return Err(error); + } + }; + + let conn = vfs.get_connection().await?; + OverlayFS::init_schema(&conn, base_path.to_string_lossy().as_ref()) + .await + .context("Failed to initialize overlay metadata for seeding")?; + drop(conn); + + eprintln!( + "Seeding {} paths and {} whiteouts...", + plan.seeded_paths.len(), + plan.whiteout_paths.len() + ); + import_entries(&vfs, &plan.entries).await?; + ensure_git_snapshot_unchanged(&base_path, &plan.snapshot)?; + let seeded_paths = plan.seeded_paths.iter().cloned().collect::>(); + let whiteout_paths = plan.whiteout_paths.iter().cloned().collect::>(); + let whiteout_db_paths = whiteout_paths + .iter() + .map(|path| format!("/{path}")) + .collect::>(); + vfs.record_seed_state(&seeded_paths, &whiteout_db_paths) + .await + .context("Failed to record seed metadata")?; + vfs.fs + .finalize() + .await + .context("Failed to finalize seeded session database")?; + drop(vfs); + super::safety::remove_sqlite_sidecars_after_checkpoint(staging.path())?; + ensure_session_inactive(&session_dir)?; + if publish_base_path { + fs::write( + session_dir.join("base_path"), + base_path.to_string_lossy().as_bytes(), + ) + .context("Failed to publish session base path")?; + } + publish_staged_database(staging.path(), &db_path)?; + + Ok(SeededSession { + summary: SeedSummary { + seeded_paths, + whiteout_paths, + local_commits: plan.local_commits, + pin: plan.pin, + }, + session_lock, + }) +} + +struct StagedDatabase { + path: PathBuf, +} + +impl StagedDatabase { + fn new(path: PathBuf) -> Self { + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for StagedDatabase { + fn drop(&mut self) { + super::pack::remove_database_family(&self.path); + } +} + +#[cfg(test)] +async fn prepare_session_database( + db_path: &Path, + base_path: &Path, + encryption: Option, +) -> Result<()> { + let mut options = VfsOptions::with_path(db_path.to_string_lossy()) + .with_core_config(crate::config::core_config_from_env()); + if let Some(encryption) = encryption { + options = options.with_encryption(encryption); + } + let vfs = Vfs::open(options) + .await + .context("Failed to create session database before seeding")?; + let conn = vfs.get_connection().await?; + OverlayFS::init_schema(&conn, base_path.to_string_lossy().as_ref()) + .await + .context("Failed to initialize session overlay before seeding")?; + drop(conn); + vfs.fs + .finalize() + .await + .context("Failed to finalize session database before seeding") +} + +struct SeedPlan { + entries: Vec, + seeded_paths: BTreeSet, + whiteout_paths: BTreeSet, + local_commits: u64, + pin: String, + snapshot: GitSnapshot, + _temp_git_pack: Option, +} + +enum PlannedEntry { + Host { + source: PathBuf, + path: String, + mode: u32, + fingerprint: FileFingerprint, + }, + Inline(ImportEntry), +} + +impl PlannedEntry { + fn path(&self) -> &str { + match self { + Self::Host { path, .. } => path, + Self::Inline(entry) => &entry.path, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct FileFingerprint { + mode: u32, + size: u64, + modified_secs: i64, + modified_nanos: i64, + changed_secs: i64, + changed_nanos: i64, +} + +impl FileFingerprint { + fn from_metadata(metadata: &fs::Metadata) -> Self { + Self { + mode: metadata.mode(), + size: metadata.size(), + modified_secs: metadata.mtime(), + modified_nanos: metadata.mtime_nsec(), + changed_secs: metadata.ctime(), + changed_nanos: metadata.ctime_nsec(), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +struct GitSnapshot { + head: String, + head_file_sha256: String, + index_sha256: String, + status: Vec, +} + +fn build_seed_plan(base_path: &Path, requested_pin: &str) -> Result { + ensure_git_repository(base_path)?; + let pin = resolve_commit(base_path, requested_pin) + .with_context(|| format!("invalid seed pin: {requested_pin}"))?; + let head = resolve_commit(base_path, "HEAD").context("git repository has no valid HEAD")?; + ensure_pin_is_ancestor(base_path, &pin, &head)?; + + let snapshot = git_snapshot(base_path, &head)?; + let mut candidates = status_paths(&snapshot.status)?; + let (tracked_candidates, deleted_paths) = diff_paths(base_path, &pin)?; + candidates.extend(tracked_candidates); + + let mut entries = Vec::new(); + let mut seeded_paths = BTreeSet::new(); + let mut whiteout_paths = BTreeSet::new(); + for path in candidates { + let host_path = base_path.join(&path); + match fs::symlink_metadata(&host_path) { + Ok(metadata) if metadata.file_type().is_file() => { + entries.push(host_entry(&host_path, path.clone(), &metadata)); + seeded_paths.insert(path); + } + Ok(metadata) if metadata.file_type().is_symlink() => { + entries.push(host_entry(&host_path, path.clone(), &metadata)); + seeded_paths.insert(path); + } + Ok(_) => bail!("unsupported non-file git path: {path}"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if deleted_paths.contains(&path) { + whiteout_paths.insert(path); + } + } + Err(error) => { + return Err(error) + .with_context(|| format!("Failed to inspect {}", host_path.display())); + } + } + } + + let local_commits = git_capture_text( + base_path, + &["rev-list", "--count", &format!("{pin}..{head}")], + )? + .trim() + .parse::() + .context("git returned an invalid local commit count")?; + let temp_git_pack = add_git_state( + base_path, + &pin, + &head, + local_commits, + &mut entries, + &mut seeded_paths, + )?; + + Ok(SeedPlan { + entries, + seeded_paths, + whiteout_paths, + local_commits, + pin, + snapshot, + _temp_git_pack: temp_git_pack, + }) +} + +fn ensure_git_repository(base_path: &Path) -> Result<()> { + let inside = git_capture_text(base_path, &["rev-parse", "--is-inside-work-tree"]) + .context("session base is not a git repository")?; + if inside.trim() != "true" { + bail!("session base is not a git repository"); + } + Ok(()) +} + +fn resolve_commit(base_path: &Path, revision: &str) -> Result { + Ok(git_capture_text( + base_path, + &["rev-parse", "--verify", &format!("{revision}^{{commit}}")], + )? + .trim() + .to_string()) +} + +fn ensure_pin_is_ancestor(base_path: &Path, pin: &str, head: &str) -> Result<()> { + let status = git_status(base_path, &["merge-base", "--is-ancestor", pin, head])?; + match status.code() { + Some(0) => Ok(()), + Some(1) => bail!("seed pin is not an ancestor of HEAD: {pin}"), + _ => bail!("git merge-base --is-ancestor failed with {status}"), + } +} + +fn git_snapshot(base_path: &Path, expected_head: &str) -> Result { + let head = resolve_commit(base_path, "HEAD")?; + if head != expected_head { + bail!("git checkout changed while seeding; retry"); + } + let git_dir_text = git_capture_text(base_path, &["rev-parse", "--git-dir"])?; + let status = git_capture( + base_path, + &["status", "--porcelain=v2", "-z", "--untracked-files=all"], + )?; + let git_dir = absolute_git_dir(base_path, git_dir_text.trim()); + let head_file = fs::read(git_dir.join("HEAD")) + .context("Failed to read git HEAD while capturing seed snapshot")?; + let index = fs::read(git_dir.join("index")) + .context("Failed to read git index while capturing seed snapshot")?; + Ok(GitSnapshot { + head, + head_file_sha256: hex::encode(Sha256::digest(head_file)), + index_sha256: hex::encode(Sha256::digest(index)), + status, + }) +} + +fn ensure_git_snapshot_unchanged(base_path: &Path, expected: &GitSnapshot) -> Result<()> { + let current = git_snapshot(base_path, &expected.head)?; + if ¤t != expected { + bail!("git checkout changed while seeding; retry"); + } + Ok(()) +} + +fn status_paths(output: &[u8]) -> Result> { + let records = output.split(|byte| *byte == 0).collect::>(); + let mut paths = BTreeSet::new(); + let mut index = 0; + while index < records.len() { + let record = records[index]; + if record.is_empty() { + index += 1; + continue; + } + match record[0] { + b'1' => paths.insert(field_after_spaces(record, 8)?), + b'2' => { + let inserted = paths.insert(field_after_spaces(record, 9)?); + index += 1; + let old = records + .get(index) + .context("malformed porcelain v2 rename record")?; + paths.insert(path_string(old)?); + inserted + } + b'u' => paths.insert(field_after_spaces(record, 10)?), + b'?' => paths.insert(path_string(record.get(2..).unwrap_or_default())?), + b'!' => false, + other => bail!("unsupported git status record type: {}", other as char), + }; + index += 1; + } + Ok(paths) +} + +fn diff_paths(base_path: &Path, pin: &str) -> Result<(BTreeSet, BTreeSet)> { + let output = git_capture(base_path, &["diff", "--name-status", "-z", pin])?; + let records = output.split(|byte| *byte == 0).collect::>(); + let mut paths = BTreeSet::new(); + let mut deleted = BTreeSet::new(); + let mut index = 0; + while index < records.len() { + let status = records[index]; + if status.is_empty() { + index += 1; + continue; + } + index += 1; + let first = records + .get(index) + .context("malformed git diff --name-status output")?; + let first = path_string(first)?; + match status[0] { + b'D' => { + deleted.insert(first); + } + b'R' => { + deleted.insert(first); + index += 1; + let second = records.get(index).context("malformed git rename output")?; + paths.insert(path_string(second)?); + } + b'C' => { + index += 1; + let second = records.get(index).context("malformed git copy output")?; + paths.insert(path_string(second)?); + } + _ => { + paths.insert(first); + } + } + index += 1; + } + Ok((paths, deleted)) +} + +fn field_after_spaces(record: &[u8], spaces: usize) -> Result { + let mut seen = 0; + for (index, byte) in record.iter().enumerate() { + if *byte == b' ' { + seen += 1; + if seen == spaces { + return path_string(&record[index + 1..]); + } + } + } + bail!("malformed git status record") +} + +fn path_string(bytes: &[u8]) -> Result { + let path = std::str::from_utf8(bytes).context("non-UTF-8 git paths are not supported")?; + validate_relative_path(path)?; + Ok(path.to_string()) +} + +fn validate_relative_path(path: &str) -> Result<()> { + if path.is_empty() || Path::new(path).is_absolute() { + bail!("invalid repository-relative path: {path:?}"); + } + if Path::new(path) + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + bail!("invalid repository-relative path: {path:?}"); + } + Ok(()) +} + +fn host_entry(host_path: &Path, path: String, metadata: &fs::Metadata) -> PlannedEntry { + PlannedEntry::Host { + source: host_path.to_path_buf(), + path, + mode: metadata.mode(), + fingerprint: FileFingerprint::from_metadata(metadata), + } +} + +fn add_git_state( + base_path: &Path, + pin: &str, + head: &str, + local_commits: u64, + entries: &mut Vec, + seeded_paths: &mut BTreeSet, +) -> Result> { + let git_dir_text = git_capture_text(base_path, &["rev-parse", "--git-dir"])?; + let git_dir = absolute_git_dir(base_path, git_dir_text.trim()); + + add_host_git_file(&git_dir, "HEAD", entries, seeded_paths)?; + add_host_git_file(&git_dir, "index", entries, seeded_paths)?; + + if let Ok(symbolic_head) = git_capture_text(base_path, &["symbolic-ref", "-q", "HEAD"]) { + let symbolic_head = symbolic_head.trim(); + validate_relative_path(symbolic_head)?; + entries.push(PlannedEntry::Inline(ImportEntry { + path: format!(".git/{symbolic_head}"), + mode: S_IFREG | 0o644, + data: format!("{head}\n").into_bytes(), + })); + seeded_paths.insert(format!(".git/{symbolic_head}")); + } + + if local_commits > 0 { + let pack_dir = TempGitPackDir::new()?; + let pack_base = pack_dir.path().join("seed"); + let mut command = git_command(base_path); + command + .args(["pack-objects", "--revs"]) + .arg(&pack_base) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().context("Failed to run git pack-objects")?; + { + let mut stdin = child + .stdin + .take() + .context("git pack-objects has no stdin")?; + writeln!(stdin, "{head}")?; + writeln!(stdin, "^{pin}")?; + } + let output = child + .wait_with_output() + .context("Failed to wait for git pack-objects")?; + if !output.status.success() { + bail!( + "git pack-objects failed with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + } + let hash = String::from_utf8(output.stdout)?.trim().to_string(); + if hash.is_empty() { + bail!("git pack-objects returned an empty pack hash"); + } + for extension in ["pack", "idx"] { + let source = pack_dir.path().join(format!("seed-{hash}.{extension}")); + let path = format!(".git/objects/pack/pack-{hash}.{extension}"); + let metadata = fs::metadata(&source) + .with_context(|| format!("Failed to inspect {}", source.display()))?; + entries.push(host_entry(&source, path.clone(), &metadata)); + seeded_paths.insert(path); + } + return Ok(Some(pack_dir)); + } + Ok(None) +} + +struct TempGitPackDir { + path: PathBuf, +} + +impl TempGitPackDir { + fn new() -> Result { + let path = std::env::temp_dir().join(format!("vfs-seed-{}", uuid::Uuid::new_v4())); + fs::create_dir(&path) + .with_context(|| format!("Failed to create temporary directory {}", path.display()))?; + Ok(Self { path }) + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempGitPackDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn absolute_git_dir(base_path: &Path, git_dir: &str) -> PathBuf { + let git_dir = PathBuf::from(git_dir); + if git_dir.is_absolute() { + git_dir + } else { + base_path.join(git_dir) + } +} + +fn add_host_git_file( + git_dir: &Path, + relative: &str, + entries: &mut Vec, + seeded_paths: &mut BTreeSet, +) -> Result<()> { + let source = git_dir.join(relative); + let metadata = fs::symlink_metadata(&source) + .with_context(|| format!("Failed to inspect git state {}", source.display()))?; + let path = format!(".git/{relative}"); + entries.push(host_entry(&source, path.clone(), &metadata)); + seeded_paths.insert(path); + Ok(()) +} + +fn parent_directories(entries: &[PlannedEntry]) -> Vec { + let mut dirs = BTreeSet::new(); + for entry in entries { + let mut parent = Path::new(entry.path()).parent(); + while let Some(path) = parent { + if path.as_os_str().is_empty() { + break; + } + dirs.insert(path.to_string_lossy().replace('\\', "/")); + parent = path.parent(); + } + } + let mut directories = dirs + .into_iter() + .map(|path| ImportEntry { + path, + mode: S_IFDIR | 0o755, + data: Vec::new(), + }) + .collect::>(); + directories.sort_by(|left, right| { + left.path + .matches('/') + .count() + .cmp(&right.path.matches('/').count()) + .then_with(|| left.path.cmp(&right.path)) + }); + directories +} + +async fn import_entries(vfs: &Vfs, entries: &[PlannedEntry]) -> Result<()> { + if entries.is_empty() { + return Ok(()); + } + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?; + let mut session = vfs + .fs + .begin_import( + ROOT_INO, + ImportOptions { + uid: unsafe { libc::geteuid() }, + gid: unsafe { libc::getegid() }, + timestamp: (timestamp.as_secs() as i64, timestamp.subsec_nanos() as i64), + }, + ) + .await + .context("Failed to begin seed import")?; + let directories = parent_directories(entries); + for chunk in directories.chunks(512) { + session + .import_chunk(chunk) + .await + .context("Failed to import seed directories")?; + } + + const CHUNK_BYTES: usize = 4 * 1024 * 1024; + const CHUNK_ENTRIES: usize = 512; + let mut chunk = Vec::new(); + let mut chunk_bytes = 0usize; + for planned in entries { + let entry = materialize_entry(planned)?; + chunk_bytes += entry.data.len(); + chunk.push(entry); + if chunk_bytes >= CHUNK_BYTES || chunk.len() >= CHUNK_ENTRIES { + session + .import_chunk(&chunk) + .await + .context("Failed to import seed content")?; + chunk.clear(); + chunk_bytes = 0; + } + } + if !chunk.is_empty() { + session + .import_chunk(&chunk) + .await + .context("Failed to import seed content")?; + } + session.finish(); + Ok(()) +} + +fn materialize_entry(planned: &PlannedEntry) -> Result { + match planned { + PlannedEntry::Inline(entry) => Ok(entry.clone()), + PlannedEntry::Host { + source, + path, + mode, + fingerprint, + } => { + let before = fs::symlink_metadata(source) + .with_context(|| format!("Failed to inspect seed source {}", source.display()))?; + if FileFingerprint::from_metadata(&before) != *fingerprint { + bail!("seed source changed while reading: {}", source.display()); + } + let data = if before.file_type().is_symlink() { + fs::read_link(source) + .with_context(|| format!("Failed to read seed symlink {}", source.display()))? + .as_os_str() + .as_bytes() + .to_vec() + } else { + fs::read(source) + .with_context(|| format!("Failed to read seed file {}", source.display()))? + }; + let after = fs::symlink_metadata(source).with_context(|| { + format!("Failed to re-inspect seed source {}", source.display()) + })?; + if FileFingerprint::from_metadata(&after) != *fingerprint { + bail!("seed source changed while reading: {}", source.display()); + } + Ok(ImportEntry { + path: path.clone(), + mode: *mode, + data, + }) + } + } +} + +fn resolve_base_path( + session_dir: &Path, + requested_base_path: Option<&Path>, +) -> Result<(PathBuf, bool)> { + let path_file = session_dir.join("base_path"); + let existing = match fs::read_to_string(&path_file) { + Ok(raw) => Some(PathBuf::from(raw.trim())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + return Err(error).with_context(|| format!("Failed to read {}", path_file.display())); + } + }; + let publish = existing.is_none(); + let path = match (existing, requested_base_path) { + (Some(existing), Some(requested)) if existing != requested => { + bail!( + "session base path {} does not match requested base {}", + existing.display(), + requested.display() + ); + } + (Some(existing), _) => existing, + (None, Some(requested)) => requested.to_path_buf(), + (None, None) => bail!("session base path not found: {}", path_file.display()), + }; + if !path.is_absolute() { + bail!( + "session base path is not absolute in {}", + path_file.display() + ); + } + Ok((path, publish)) +} + +fn publish_staged_database(staging: &Path, live: &Path) -> Result<()> { + if !live.exists() { + fs::rename(staging, live) + .with_context(|| format!("Failed to publish seeded database {}", live.display()))?; + return super::pack::sync_file_and_parent(live); + } + + let backup = seed_backup_path(live); + super::pack::rename_database_family(live, &backup) + .context("Failed to stage the live database for seed publication")?; + if let Err(error) = fs::rename(staging, live) + .with_context(|| format!("Failed to publish seeded database {}", live.display())) + .and_then(|()| super::pack::sync_file_and_parent(live)) + { + super::pack::remove_database_family(live); + super::pack::rename_database_family(&backup, live) + .context("Failed to restore the live database after seed publication failed")?; + return Err(error); + } + super::pack::remove_database_family(&backup); + Ok(()) +} + +fn recover_interrupted_publication(live: &Path) -> Result<()> { + let backup = seed_backup_path(live); + if !backup.exists() { + return Ok(()); + } + if live.exists() { + super::pack::remove_database_family(&backup); + return Ok(()); + } + super::pack::rename_database_family(&backup, live) + .context("Failed to recover an interrupted seed publication")?; + super::pack::sync_file_and_parent(live) +} + +fn seed_backup_path(live: &Path) -> PathBuf { + live.with_file_name(".delta.db.seed-backup") +} + +fn ensure_session_inactive(session_dir: &Path) -> Result<()> { + if vfs_mount::is_mountpoint(&session_dir.join("mnt")) + || super::ps::procs_dir_has_live_processes(&session_dir.join("procs")) + { + return Err(SessionStillRunning.into()); + } + Ok(()) +} + +fn git_capture_text(base_path: &Path, args: &[&str]) -> Result { + String::from_utf8(git_capture(base_path, args)?).context("git output was not valid UTF-8") +} + +fn git_capture(base_path: &Path, args: &[&str]) -> Result> { + let output = git_command(base_path) + .args(args) + .output() + .with_context(|| format!("Failed to run git {}", args.join(" ")))?; + if !output.status.success() { + bail!( + "git {} failed with {}: {}", + args.join(" "), + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(output.stdout) +} + +fn git_status(base_path: &Path, args: &[&str]) -> Result { + git_command(base_path) + .args(args) + .status() + .with_context(|| format!("Failed to run git {}", args.join(" "))) +} + +fn git_command(base_path: &Path) -> Command { + let mut command = Command::new("git"); + command.arg("-C").arg(base_path); + vfs_mount::supervise::set_parent_death_signal_std(&mut command); + command +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use sha2::{Digest, Sha256}; + use tempfile::{tempdir, TempDir}; + use vfs_core::{FileSystem, HostFS}; + + use super::*; + + struct GitFixture { + _root: TempDir, + home: PathBuf, + repo: PathBuf, + origin: PathBuf, + pin: String, + local_head: String, + } + + impl GitFixture { + fn full_dirty_state() -> Result { + let root = tempdir()?; + let home = root.path().join("home"); + let repo = root.path().join("repo"); + let origin = root.path().join("origin.git"); + fs::create_dir_all(&home)?; + fs::create_dir_all(&repo)?; + git(&repo, &["init", "-b", "main"])?; + git(&repo, &["config", "user.name", "Vfs Seed Test"])?; + git(&repo, &["config", "user.email", "seed@example.com"])?; + + fs::write(repo.join(".gitignore"), "ignored.log\n")?; + fs::write(repo.join("dirty.txt"), "pin dirty\n")?; + fs::write(repo.join("delete.txt"), "delete me\n")?; + fs::write(repo.join("exec.sh"), "#!/bin/sh\necho pin\n")?; + let mut executable = fs::metadata(repo.join("exec.sh"))?.permissions(); + use std::os::unix::fs::PermissionsExt; + executable.set_mode(0o755); + fs::set_permissions(repo.join("exec.sh"), executable)?; + fs::write(repo.join("local.txt"), "pin local\n")?; + fs::write(repo.join("rename-old.txt"), "renamed\n")?; + git(&repo, &["add", "."])?; + git(&repo, &["commit", "-m", "pin"])?; + let pin = git_text(&repo, &["rev-parse", "HEAD"])?; + + git(root.path(), &["init", "--bare", origin.to_str().unwrap()])?; + git( + &repo, + &["remote", "add", "origin", origin.to_str().unwrap()], + )?; + git(&repo, &["push", "origin", "main"])?; + + fs::write(repo.join("local.txt"), "local commit\n")?; + git(&repo, &["add", "local.txt"])?; + git(&repo, &["commit", "-m", "local only"])?; + let local_head = git_text(&repo, &["rev-parse", "HEAD"])?; + + fs::write(repo.join("dirty.txt"), "dirty worktree\n")?; + fs::write(repo.join("untracked.txt"), "untracked\n")?; + fs::write(repo.join("ignored.log"), "ignored\n")?; + fs::remove_file(repo.join("delete.txt"))?; + fs::write(repo.join("exec.sh"), "#!/bin/sh\necho dirty\n")?; + fs::write(repo.join("staged.txt"), "staged\n")?; + git(&repo, &["add", "staged.txt"])?; + git(&repo, &["mv", "rename-old.txt", "rename-new.txt"])?; + + Ok(Self { + _root: root, + home, + repo, + origin, + pin, + local_head, + }) + } + + async fn create_session(&self, id: &str) -> Result { + let session_dir = self.home.join(".vfs").join("run").join(id); + fs::create_dir_all(session_dir.join("mnt"))?; + fs::create_dir_all(session_dir.join("procs"))?; + fs::write( + session_dir.join("base_path"), + self.repo.to_string_lossy().as_bytes(), + )?; + let db_path = session_dir.join("delta.db"); + prepare_session_database(&db_path, &self.repo, None).await?; + Ok(db_path) + } + + fn pristine_clone(&self, name: &str) -> Result { + let clone = self._root.path().join(name); + git( + self._root.path(), + &[ + "clone", + "--quiet", + self.origin.to_str().unwrap(), + clone.to_str().unwrap(), + ], + )?; + git(&clone, &["checkout", "--quiet", &self.pin])?; + Ok(clone) + } + } + + #[test] + fn parses_porcelain_v2_paths() -> Result<()> { + let record = b"1 .M N... 100644 100644 100644 abc def path with spaces"; + assert_eq!(field_after_spaces(record, 8)?, "path with spaces"); + Ok(()) + } + + #[test] + fn imported_modes_are_supported_file_kinds() { + assert_eq!((S_IFREG | 0o755) & vfs_core::S_IFMT, S_IFREG); + assert_eq!( + (vfs_core::S_IFLNK | 0o777) & vfs_core::S_IFMT, + vfs_core::S_IFLNK + ); + } + + #[test] + fn interrupted_seed_publication_recovers_live_database() -> Result<()> { + let dir = tempdir()?; + let live = dir.path().join("delta.db"); + let backup = seed_backup_path(&live); + fs::write(&backup, b"original")?; + + recover_interrupted_publication(&live)?; + + assert_eq!(fs::read(&live)?, b"original"); + assert!(!backup.exists()); + Ok(()) + } + + #[tokio::test] + async fn seed_round_trips_all_dirty_classes_and_git_state() -> Result<()> { + let fixture = GitFixture::full_dirty_state()?; + let db_path = fixture.create_session("seed-all").await?; + + let seeded = + seed_session(&fixture.home, "seed-all", &fixture.pin, None, false, None).await?; + let summary = seeded.summary.clone(); + assert_eq!(summary.local_commits, 1); + assert_eq!(summary.pin, fixture.pin); + assert!(summary.seeded_paths.contains(&"dirty.txt".to_string())); + assert!(summary.seeded_paths.contains(&"untracked.txt".to_string())); + assert!(summary.seeded_paths.contains(&"staged.txt".to_string())); + assert!(summary.seeded_paths.contains(&"exec.sh".to_string())); + assert!(summary.seeded_paths.contains(&"local.txt".to_string())); + assert!(summary.seeded_paths.contains(&"rename-new.txt".to_string())); + assert!(!summary.seeded_paths.contains(&"ignored.log".to_string())); + assert_eq!( + summary.whiteout_paths, + vec!["delete.txt".to_string(), "rename-old.txt".to_string()] + ); + assert!(summary + .seeded_paths + .windows(2) + .all(|pair| pair[0] <= pair[1])); + + let pristine = fixture.pristine_clone("pristine")?; + let vfs = Vfs::open(VfsOptions::with_path(db_path.to_string_lossy())).await?; + assert_eq!( + vfs.session_metadata().await?.seeded_paths, + summary.seeded_paths + ); + let overlay = OverlayFS::new(Arc::new(HostFS::new(&pristine)?), vfs.fs); + overlay.load().await?; + + assert_eq!( + overlay_read(&overlay, "dirty.txt").await?.as_deref(), + Some(b"dirty worktree\n".as_slice()) + ); + assert_eq!( + overlay_read(&overlay, "untracked.txt").await?.as_deref(), + Some(b"untracked\n".as_slice()) + ); + assert_eq!( + overlay_read(&overlay, "local.txt").await?.as_deref(), + Some(b"local commit\n".as_slice()) + ); + assert!(overlay_stats(&overlay, "delete.txt").await?.is_none()); + assert!(overlay_stats(&overlay, "ignored.log").await?.is_none()); + let exec = overlay_stats(&overlay, "exec.sh") + .await? + .context("missing executable")?; + assert_ne!(exec.mode & 0o111, 0); + + materialize_seeded_overlay( + &overlay, + &pristine, + &summary.seeded_paths, + &summary.whiteout_paths, + ) + .await?; + assert_eq!( + git_text(&pristine, &["rev-parse", "HEAD"])?, + fixture.local_head + ); + assert_eq!( + git_text( + &pristine, + &["rev-list", "--count", &format!("{}..HEAD", fixture.pin)] + )?, + "1" + ); + assert_eq!( + portable_worktree_hashes(&pristine)?, + portable_worktree_hashes(&fixture.repo)? + ); + assert_eq!( + git_bytes( + &pristine, + &["status", "--porcelain=v2", "-z", "--untracked-files=all"] + )?, + git_bytes( + &fixture.repo, + &["status", "--porcelain=v2", "-z", "--untracked-files=all"] + )? + ); + assert!(!pristine.join("ignored.log").exists()); + + drop(seeded); + let error = seed_session(&fixture.home, "seed-all", &fixture.pin, None, false, None) + .await + .expect_err("second seed must fail"); + assert_eq!(error.to_string(), "session already seeded"); + + let json = serde_json::to_value(&summary)?; + assert!(json["seededPaths"].is_array()); + assert!(json["whiteoutPaths"].is_array()); + assert_eq!(json["localCommits"], 1); + assert_eq!(json["pin"], fixture.pin); + Ok(()) + } + + #[tokio::test] + async fn seed_rejects_invalid_non_ancestor_and_live_sessions() -> Result<()> { + let fixture = GitFixture::full_dirty_state()?; + let invalid_db = fixture.create_session("invalid-pin").await?; + let invalid_before = fs::read(&invalid_db)?; + let invalid = seed_session( + &fixture.home, + "invalid-pin", + "not-a-commit", + None, + false, + None, + ) + .await + .expect_err("invalid pin must fail"); + assert!(format!("{invalid:#}").contains("invalid seed pin")); + assert_eq!(fs::read(&invalid_db)?, invalid_before); + + fixture.create_session("non-ancestor").await?; + let tree = git_text(&fixture.repo, &["rev-parse", "HEAD^{tree}"])?; + let unrelated = git_with_stdin( + &fixture.repo, + &["commit-tree", &tree], + b"unrelated commit\n", + )?; + let error = seed_session(&fixture.home, "non-ancestor", &unrelated, None, false, None) + .await + .expect_err("non-ancestor pin must fail"); + assert!(error.to_string().contains("not an ancestor")); + + fixture.create_session("live").await?; + let live_dir = fixture.home.join(".vfs/run/live"); + let _live_lock = super::super::session_lock::SessionLock::try_shared(&live_dir)?; + let base_before = fs::read(live_dir.join("base_path"))?; + let error = seed_session( + &fixture.home, + "live", + &fixture.pin, + None, + false, + Some(fixture._root.path()), + ) + .await + .expect_err("live session must fail"); + assert!(error.downcast_ref::().is_some()); + assert_eq!(fs::read(live_dir.join("base_path"))?, base_before); + Ok(()) + } + + #[tokio::test] + async fn failed_import_leaves_live_database_retryable() -> Result<()> { + use std::os::unix::ffi::OsStringExt; + + let fixture = GitFixture::full_dirty_state()?; + let db_path = fixture.create_session("retry").await?; + let before = fs::read(&db_path)?; + let bad_target = std::ffi::OsString::from_vec(vec![0xff]); + std::os::unix::fs::symlink(bad_target, fixture.repo.join("zz-invalid-link"))?; + + let error = seed_session(&fixture.home, "retry", &fixture.pin, None, false, None) + .await + .expect_err("invalid symlink target must fail"); + assert!(format!("{error:#}").contains("Failed to import seed content")); + assert_eq!(fs::read(&db_path)?, before); + + fs::remove_file(fixture.repo.join("zz-invalid-link"))?; + fs::write(fixture.repo.join("zz-invalid-link"), "now valid\n")?; + let seeded = seed_session(&fixture.home, "retry", &fixture.pin, None, false, None).await?; + assert!(seeded + .summary + .seeded_paths + .contains(&"zz-invalid-link".to_string())); + Ok(()) + } + + #[tokio::test] + async fn run_seed_creation_publishes_base_and_downgrades_lock() -> Result<()> { + let fixture = GitFixture::full_dirty_state()?; + let session_dir = fixture.home.join(".vfs/run/run-seed"); + fs::create_dir_all(session_dir.join("mnt"))?; + fs::create_dir_all(session_dir.join("procs"))?; + + let seeded = seed_session( + &fixture.home, + "run-seed", + &fixture.pin, + None, + true, + Some(&fixture.repo), + ) + .await?; + assert!(session_dir.join("delta.db").is_file()); + assert_eq!( + fs::read_to_string(session_dir.join("base_path"))?, + fixture.repo.to_string_lossy() + ); + + let run_lock = seeded.into_shared_lock()?; + assert_eq!( + super::super::session_lock::SessionLock::try_exclusive(&session_dir) + .err() + .context("exclusive lock must remain blocked")? + .kind(), + std::io::ErrorKind::WouldBlock + ); + drop(run_lock); + super::super::session_lock::SessionLock::try_exclusive(&session_dir)?; + Ok(()) + } + + async fn materialize_seeded_overlay( + overlay: &OverlayFS, + destination: &Path, + seeded_paths: &[String], + whiteout_paths: &[String], + ) -> Result<()> { + for path in whiteout_paths { + let target = destination.join(path); + if target.is_dir() { + fs::remove_dir_all(&target)?; + } else if target.exists() { + fs::remove_file(&target)?; + } + } + for path in seeded_paths { + let stats = overlay_stats(overlay, path) + .await? + .with_context(|| format!("missing seeded overlay path {path}"))?; + let target = destination.join(path); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + if stats.is_symlink() { + if target.exists() { + fs::remove_file(&target)?; + } + let target_value = overlay + .readlink(stats.ino) + .await? + .context("missing seeded symlink target")?; + std::os::unix::fs::symlink(target_value, target)?; + } else { + fs::write( + &target, + overlay_read(overlay, path).await?.unwrap_or_default(), + )?; + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&target, fs::Permissions::from_mode(stats.mode & 0o7777))?; + } + } + Ok(()) + } + + async fn overlay_read(overlay: &OverlayFS, path: &str) -> Result>> { + let Some(stats) = overlay_stats(overlay, path).await? else { + return Ok(None); + }; + let file = overlay.open(stats.ino, libc::O_RDONLY).await?; + Ok(Some(file.pread(0, stats.size as u64).await?)) + } + + async fn overlay_stats(overlay: &OverlayFS, path: &str) -> Result> { + let mut ino = ROOT_INO; + let mut stats = None; + for component in path.split('/').filter(|component| !component.is_empty()) { + stats = overlay.lookup(ino, component).await?; + let Some(found) = stats.as_ref() else { + return Ok(None); + }; + ino = found.ino; + } + Ok(stats) + } + + fn git(repo: &Path, args: &[&str]) -> Result<()> { + let output = git_output(repo, args, None)?; + if !output.status.success() { + bail!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(()) + } + + fn git_text(repo: &Path, args: &[&str]) -> Result { + Ok(String::from_utf8(git_bytes(repo, args)?)? + .trim() + .to_string()) + } + + fn git_bytes(repo: &Path, args: &[&str]) -> Result> { + let output = git_output(repo, args, None)?; + if !output.status.success() { + bail!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(output.stdout) + } + + fn git_with_stdin(repo: &Path, args: &[&str], stdin: &[u8]) -> Result { + let output = git_output(repo, args, Some(stdin))?; + if !output.status.success() { + bail!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(String::from_utf8(output.stdout)?.trim().to_string()) + } + + fn git_output( + repo: &Path, + args: &[&str], + stdin: Option<&[u8]>, + ) -> Result { + let mut command = Command::new("git"); + command + .arg("-C") + .arg(repo) + .args(args) + .env("GIT_AUTHOR_NAME", "Vfs Seed Test") + .env("GIT_AUTHOR_EMAIL", "seed@example.com") + .env("GIT_COMMITTER_NAME", "Vfs Seed Test") + .env("GIT_COMMITTER_EMAIL", "seed@example.com") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if stdin.is_some() { + command.stdin(Stdio::piped()); + } + let mut child = command.spawn()?; + if let Some(stdin) = stdin { + child + .stdin + .take() + .context("git child has no stdin")? + .write_all(stdin)?; + } + Ok(child.wait_with_output()?) + } + + fn portable_worktree_hashes(repo: &Path) -> Result> { + let paths = git_bytes( + repo, + &[ + "ls-files", + "-z", + "--cached", + "--others", + "--exclude-standard", + ], + )?; + let mut hashes = Vec::new(); + for path in paths + .split(|byte| *byte == 0) + .filter(|path| !path.is_empty()) + { + let path = path_string(path)?; + let host_path = repo.join(&path); + let Ok(metadata) = fs::symlink_metadata(&host_path) else { + continue; + }; + let data = if metadata.file_type().is_symlink() { + fs::read_link(&host_path)?.as_os_str().as_bytes().to_vec() + } else { + fs::read(&host_path)? + }; + hashes.push(( + path, + metadata.mode() & 0o170777, + hex::encode(Sha256::digest(data)), + )); + } + hashes.sort(); + Ok(hashes) + } +} diff --git a/crates/vfs-cli/src/cmd/session_lock.rs b/crates/vfs-cli/src/cmd/session_lock.rs index 29ca3e64..b1a269dc 100644 --- a/crates/vfs-cli/src/cmd/session_lock.rs +++ b/crates/vfs-cli/src/cmd/session_lock.rs @@ -1,32 +1,58 @@ -//! Cross-process session locking shared by `vfs run` and `vfs pack`. +//! Cross-process session locking shared by `vfs run`, `vfs seed`, and `vfs pack`. use std::fs::{File, OpenOptions}; use std::io; use std::path::Path; -/// Advisory lock held for the lifetime of a run or pack operation. +/// Advisory lock held for the lifetime of a run, seed, or pack operation. pub(crate) struct SessionLock { _file: File, } +impl Drop for SessionLock { + fn drop(&mut self) { + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + + let _ = unsafe { libc::flock(self._file.as_raw_fd(), libc::LOCK_UN) }; + } + } +} + impl SessionLock { /// Acquire a shared lock for a run owner or joiner. pub(crate) fn try_shared(session_dir: &Path) -> io::Result { Self::try_acquire(session_dir, libc::LOCK_SH) } - /// Acquire an exclusive lock for pack. + /// Acquire an exclusive lock for seed or pack. pub(crate) fn try_exclusive(session_dir: &Path) -> io::Result { Self::try_acquire(session_dir, libc::LOCK_EX) } + /// Atomically downgrade an exclusive seed lock to the run lifetime lock. + pub(crate) fn downgrade_to_shared(self) -> io::Result { + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + + if unsafe { libc::flock(self._file.as_raw_fd(), libc::LOCK_SH) } != 0 { + return Err(io::Error::last_os_error()); + } + } + Ok(self) + } + fn try_acquire(session_dir: &Path, mode: libc::c_int) -> io::Result { - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(session_dir.join(".session.lock"))?; + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_CLOEXEC); + } + let file = options.open(session_dir.join(".session.lock"))?; #[cfg(unix)] { use std::os::fd::AsRawFd; @@ -59,4 +85,19 @@ mod tests { drop(second_run); SessionLock::try_exclusive(dir.path()).unwrap(); } + + #[test] + fn seed_lock_downgrades_without_releasing_session_ownership() { + let dir = tempdir().unwrap(); + let seed = SessionLock::try_exclusive(dir.path()).unwrap(); + let run = seed.downgrade_to_shared().unwrap(); + let joiner = SessionLock::try_shared(dir.path()).unwrap(); + assert_eq!( + SessionLock::try_exclusive(dir.path()).err().unwrap().kind(), + io::ErrorKind::WouldBlock + ); + drop(run); + drop(joiner); + SessionLock::try_exclusive(dir.path()).unwrap(); + } } diff --git a/crates/vfs-cli/src/cmd/version.rs b/crates/vfs-cli/src/cmd/version.rs index 90b8e750..9d47305e 100644 --- a/crates/vfs-cli/src/cmd/version.rs +++ b/crates/vfs-cli/src/cmd/version.rs @@ -31,7 +31,7 @@ pub fn handle_version_command(stdout: &mut impl Write, json: bool) -> Result<()> features: VersionFeatures { uid_squash_run: cfg!(target_os = "linux"), pack: true, - seed: false, + seed: true, }, }; @@ -58,6 +58,6 @@ mod tests { assert_eq!(value["version"], env!("CARGO_PKG_VERSION")); assert_eq!(value["features"]["uidSquashRun"], cfg!(target_os = "linux")); assert_eq!(value["features"]["pack"], true); - assert_eq!(value["features"]["seed"], false); + assert_eq!(value["features"]["seed"], true); } } diff --git a/crates/vfs-cli/src/main.rs b/crates/vfs-cli/src/main.rs index e5daa623..eaf5f631 100644 --- a/crates/vfs-cli/src/main.rs +++ b/crates/vfs-cli/src/main.rs @@ -149,6 +149,7 @@ fn dispatch(args: Args) -> anyhow::Result<()> { partial_origin_threshold_bytes, key, cipher, + seed_pin, command, args, } => { @@ -164,6 +165,7 @@ fn dispatch(args: Args) -> anyhow::Result<()> { partial_origin, partial_origin_threshold_bytes, ), + seed_pin, command: command.unwrap_or_else(default_shell), args, }; @@ -350,6 +352,19 @@ fn dispatch(args: Args) -> anyhow::Result<()> { json, )) } + Command::Seed { + session_id, + pin, + json, + } => { + let rt = get_runtime(); + rt.block_on(cmd::seed::handle_seed_command( + &mut std::io::stdout(), + session_id, + pin, + json, + )) + } Command::Version { json } => { cmd::version::handle_version_command(&mut std::io::stdout(), json) } diff --git a/crates/vfs-cli/src/opts.rs b/crates/vfs-cli/src/opts.rs index 60fb499a..59a7d422 100644 --- a/crates/vfs-cli/src/opts.rs +++ b/crates/vfs-cli/src/opts.rs @@ -88,6 +88,8 @@ pub struct RunOptions { pub encryption: Option, /// Partial-origin copy-up policy resolved from CLI flags. pub partial_origin_policy: Option, + /// Git commit whose pristine tree is the portable session base. + pub seed_pin: Option, /// Command to execute inside the sandbox. pub command: PathBuf, /// Arguments for the command. @@ -231,6 +233,11 @@ pub enum Command { #[arg(long, env = "VFS_CIPHER")] cipher: Option, + /// Seed dirty and local-commit state against this git commit before mounting. + /// Ignored files are not part of portable state. + #[arg(long = "seed-pin", value_name = "COMMIT")] + seed_pin: Option, + /// Command to execute (defaults to bash on Linux, zsh on macOS) command: Option, @@ -453,6 +460,24 @@ pub enum Command { #[arg(long)] json: bool, }, + /// Capture a run session's live git state into its portable delta. + /// + /// Compares the session base checkout with --pin, imports dirty files and + /// local commits without mounting, and records deletions as whiteouts. + /// Ignored files are not part of portable state. + Seed { + /// Run session identifier + #[arg(value_name = "SESSION_ID")] + session_id: String, + + /// Git commit used as the pristine portable base + #[arg(long, value_name = "COMMIT")] + pin: String, + + /// Emit machine-readable JSON (seed output is always JSON) + #[arg(long)] + json: bool, + }, /// Show vfs version and feature capabilities Version { /// Emit machine-readable JSON @@ -742,6 +767,8 @@ mod tests { "auto", "--partial-origin-threshold-bytes", "4096", + "--seed-pin", + "abc123", "bash", ]) .unwrap(); @@ -750,11 +777,13 @@ mod tests { Command::Run { partial_origin, partial_origin_threshold_bytes, + seed_pin, command, .. } => { assert_eq!(partial_origin, Some(PartialOriginMode::Auto)); assert_eq!(partial_origin_threshold_bytes, Some(4096)); + assert_eq!(seed_pin.as_deref(), Some("abc123")); assert_eq!(command, Some(PathBuf::from("bash"))); } other => panic!("expected run command, got {other:?}"), @@ -825,6 +854,25 @@ mod tests { } } + #[test] + fn seed_options_parse() { + let args = Args::try_parse_from(["vfs", "seed", "session-1", "--pin", "abc123", "--json"]) + .unwrap(); + + match args.command { + Command::Seed { + session_id, + pin, + json, + } => { + assert_eq!(session_id, "session-1"); + assert_eq!(pin, "abc123"); + assert!(json); + } + other => panic!("expected seed command, got {other:?}"), + } + } + #[test] fn version_json_option_parses() { let args = Args::try_parse_from(["vfs", "version", "--json"]).unwrap(); diff --git a/crates/vfs-core/src/fs/overlay/mod.rs b/crates/vfs-core/src/fs/overlay/mod.rs index a5e9de89..34f15cd5 100644 --- a/crates/vfs-core/src/fs/overlay/mod.rs +++ b/crates/vfs-core/src/fs/overlay/mod.rs @@ -95,6 +95,18 @@ fn current_timestamp() -> Result<(i64, i64)> { Ok((dur.as_secs() as i64, dur.subsec_nanos() as i64)) } +pub(crate) fn parent_path_for_whiteout(path: &str) -> String { + if path == "/" { + return "/".to_string(); + } + + let trimmed = path.trim_end_matches('/'); + match trimmed.rfind('/') { + Some(0) | None => "/".to_string(), + Some(index) => trimmed[..index].to_string(), + } +} + fn is_write_open(flags: i32) -> bool { (flags & libc::O_ACCMODE) != libc::O_RDONLY || (flags & libc::O_TRUNC) != 0 } diff --git a/crates/vfs-core/src/fs/overlay/whiteouts.rs b/crates/vfs-core/src/fs/overlay/whiteouts.rs index d5321542..bb679368 100644 --- a/crates/vfs-core/src/fs/overlay/whiteouts.rs +++ b/crates/vfs-core/src/fs/overlay/whiteouts.rs @@ -1,18 +1,6 @@ use super::*; use turso::transaction::{Transaction, TransactionBehavior}; -fn parent_path_for_whiteout(path: &str) -> String { - if path == "/" { - return "/".to_string(); - } - - let trimmed = path.trim_end_matches('/'); - match trimmed.rfind('/') { - Some(0) | None => "/".to_string(), - Some(index) => trimmed[..index].to_string(), - } -} - impl OverlayFS { /// Check if a path is whiteout (deleted from base). pub(super) fn is_whiteout(&self, path: &str) -> bool { diff --git a/crates/vfs-core/src/session.rs b/crates/vfs-core/src/session.rs index 497229ad..9e171de3 100644 --- a/crates/vfs-core/src/session.rs +++ b/crates/vfs-core/src/session.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use std::path::Path; +use turso::transaction::{Transaction, TransactionBehavior}; use turso::{Builder, Connection, Value}; use crate::error::{Error, Result}; @@ -65,6 +66,48 @@ impl Vfs { write_metadata_value(&conn, SEEDED_PATHS_KEY, value).await } + /// Atomically persist seed whiteouts and the path manifest. + /// + /// Content import commits before this call; publishing the whiteouts and + /// manifest together prevents a completed seed from exposing only half of + /// its deletion state. + pub async fn record_seed_state( + &self, + seeded_paths: &[String], + whiteout_paths: &[String], + ) -> Result<()> { + let conn = self.pool.get_connection().await?; + let txn = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate).await?; + let result = async { + let created_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() as i64; + for path in whiteout_paths { + let parent_path = crate::fs::overlay::parent_path_for_whiteout(path); + conn.execute( + "INSERT OR REPLACE INTO fs_whiteout (path, parent_path, created_at) + VALUES (?, ?, ?)", + (path.as_str(), parent_path, created_at), + ) + .await?; + } + let value = serde_json::to_string(seeded_paths)?; + write_metadata_value(&conn, SEEDED_PATHS_KEY, value).await + } + .await; + + match result { + Ok(()) => { + txn.commit().await?; + Ok(()) + } + Err(error) => { + let _ = txn.rollback().await; + Err(error) + } + } + } + /// Checkpoint and compact a local database into a new single-file artifact. pub async fn compact_local_database_into(&self, output: &Path) -> Result<()> { self.fs.finalize().await?; @@ -199,6 +242,17 @@ mod tests { seeded_paths: seeded_paths.clone(), } ); + let seeded_paths = vec!["src/main.rs".to_string(), "deleted.txt".to_string()]; + vfs.record_seed_state(&seeded_paths, &["/deleted.txt".to_string()]) + .await?; + assert_eq!( + vfs.session_metadata().await?.seeded_paths, + seeded_paths.clone() + ); + assert_eq!( + vfs.get_whiteouts().await?, + std::collections::HashSet::from(["/deleted.txt".to_string()]) + ); drop(vfs); let vfs = Vfs::open(VfsOptions::with_path(db_path.to_string_lossy())).await?; assert_eq!(vfs.session_metadata().await?.generation, 2); diff --git a/docs/MANUAL.md b/docs/MANUAL.md index c9e2ca6d..a36bd0c2 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -213,6 +213,7 @@ vfs run [OPTIONS] [COMMAND] [ARGS]... - `--partial-origin-threshold-bytes ` — Size threshold for --partial-origin auto - `--key ` — Hex-encoded encryption key for the delta layer. Enables local encryption when provided [env: VFS_KEY] - `--cipher ` — Cipher algorithm for encryption (required with --key). Options: aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4, aes128gcm, aes256gcm [env: VFS_CIPHER] +- `--seed-pin ` — Seed dirty and local-commit state against this git commit before mounting. Ignored files are not part of portable state ### vfs exec @@ -412,6 +413,25 @@ vfs pack [OPTIONS] - `--output ` — Copy the packed database to this path - `--json` — Emit machine-readable JSON (pack output is always JSON) +### vfs seed + +Capture a run session's live git state into its portable delta. + +Compares the session base checkout with --pin, imports dirty files and local commits without mounting, and records deletions as whiteouts. Ignored files are not part of portable state. + +``` +vfs seed [OPTIONS] +``` + +**Arguments:** + +- `` — Run session identifier + +**Options:** + +- `--pin ` — Git commit used as the pristine portable base +- `--json` — Emit machine-readable JSON (seed output is always JSON) + ### vfs version Show vfs version and feature capabilities @@ -535,6 +555,40 @@ vfs migrate [OPTIONS] +## Seeding run sessions + +`vfs seed --pin ` captures the live checkout state that +would otherwise be visible only through overlay base read-through. It imports +dirty and untracked files, records paths deleted since the pin as whiteouts, +and includes committed changes between the pin and `HEAD`. The import uses the +core bulk-import API directly; it never requires a FUSE or NFS mount. + +For session creation, prefer the atomic startup form: + +```bash +vfs run --session --seed-pin -- +``` + +`vfs run` creates and seeds the delta under the session's exclusive lock, then +atomically downgrades that lock to the lifetime shared run lock before mounting +or starting the wrapped process. Seed writes a private staging database and +publishes it only after import, whiteouts, metadata, and finalization succeed, +so a failed seed leaves the live delta retryable. The standalone `vfs seed` +form is available when a session directory, `base_path`, and `delta.db` already +exist and the session is inactive. + +Git-ignored files are not part of portable state. Seed uses +`git status --untracked-files=all`, so ignored build outputs, dependency trees, +and caches remain base-local. For local-only commits, seed writes a compact Git +pack plus `HEAD` and the current branch ref into delta `.git/` paths. It also +copies the sender's index bytes, preserving staged versus unstaged state; Git +revalidates cached stat fields when the delta is materialized over a pristine +checkout at the pin. + +Seed is a birth-time operation. A second call fails with +`session already seeded`, and a live mount or wrapped process fails with the +same exit-code-3 teardown gate used by `vfs pack`. + ## Packing run sessions `vfs pack ` prepares `~/.vfs/run//delta.db` as a