From 129d5f3f4ca90e466291e96ddf6423e9692dffb1 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 17:49:20 -0500 Subject: [PATCH 1/6] fix(images): include snapshot helper in agent build context Signed-off-by: Tee8z --- voltage/images/buzz-agent/Dockerfile.dockerignore | 1 + 1 file changed, 1 insertion(+) diff --git a/voltage/images/buzz-agent/Dockerfile.dockerignore b/voltage/images/buzz-agent/Dockerfile.dockerignore index a021be5b0b5..210d6c43de9 100644 --- a/voltage/images/buzz-agent/Dockerfile.dockerignore +++ b/voltage/images/buzz-agent/Dockerfile.dockerignore @@ -89,6 +89,7 @@ voltage/ !voltage/images/buzz-agent/profile.py !voltage/images/buzz-agent/cache.py !voltage/images/buzz-agent/checkpoint.py +!voltage/images/buzz-agent/snapshot.py !voltage/images/buzz-agent/review.py !voltage/images/buzz-agent/checkpoint-gitleaks.toml !voltage/images/buzz-agent/gh.sh From 5e471aa9aa687e0ded64868ac40f79ca9abe6e10 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 17:45:45 -0500 Subject: [PATCH 2/6] fix(acp): admit full encrypted terminal data frames Signed-off-by: Tee8z --- .../buzz-acp/src/sandbox_terminal/server.rs | 12 +- .../src/sandbox_terminal/server/tests.rs | 184 +++++++++++++++++- docs/sandbox-attach.md | 2 + 3 files changed, 194 insertions(+), 4 deletions(-) diff --git a/crates/buzz-acp/src/sandbox_terminal/server.rs b/crates/buzz-acp/src/sandbox_terminal/server.rs index 7ab193d9d93..34c87980a82 100644 --- a/crates/buzz-acp/src/sandbox_terminal/server.rs +++ b/crates/buzz-acp/src/sandbox_terminal/server.rs @@ -24,6 +24,7 @@ const IDLE: Duration = Duration::from_secs(300); const DRAIN: Duration = Duration::from_secs(6); const SUBSCRIPTION: &str = "sandbox-terminal"; +#[derive(Clone)] pub(super) struct Config { pub(super) relay: String, pub(super) keys: Keys, @@ -110,6 +111,13 @@ fn elapsed(start: Instant) -> u64 { u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX) } +fn frame_admitted(event: &Event, budget: &mut Rate, ms: u64) -> bool { + // NIP44 padding/base64 can exceed twice the decoded packet budget. Use + // the shared codec's bounded envelope before any signature/decryption; + // Packet::bytes separately enforces the 16KiB decoded data limit. + buzz_core::observer::content_looks_like_nip44(&event.content) && budget.allow(ms) +} + fn authority_deadline(grant: &Grant, checked_at: Instant) -> Instant { let expiry = std::time::UNIX_EPOCH + Duration::from_secs(grant.expires_at()); let remaining = expiry @@ -223,7 +231,7 @@ async fn open( // Bound ciphertext before decryption. The second typed decryption binds // the discovered approval ID to the complete signed envelope. ensure!( - event.content.len() <= 32 * 1024 && event.verify().is_ok(), + buzz_core::observer::content_looks_like_nip44(&event.content) && event.verify().is_ok(), "invalid terminal open" ); let discovered: Frame = buzz_core::observer::decrypt_observer_payload(&config.keys, event)?; @@ -421,7 +429,7 @@ async fn run(config: &Config, rest: &RestClient, mut shutdown: watch::Receiver<( RelayMessage::Closed { subscription_id, .. } if subscription_id == SUBSCRIPTION => bail!("terminal subscription closed"), RelayMessage::Event { subscription_id, event } if subscription_id == SUBSCRIPTION => { let ms = elapsed(start); - if event.content.len() > 32 * 1024 || !incoming.allow(ms) { continue; } + if !frame_admitted(&event, &mut incoming, ms) { continue; } if let Some(session) = &mut active { if event.pubkey != session.grant.requester() { continue; } let Ok(frame) = attach::decrypt(&config.keys, event.pubkey, config.scope, session.grant.id(), &event, Timestamp::now().as_secs()) else { continue; }; diff --git a/crates/buzz-acp/src/sandbox_terminal/server/tests.rs b/crates/buzz-acp/src/sandbox_terminal/server/tests.rs index f9624d2d6af..ed5376fe807 100644 --- a/crates/buzz-acp/src/sandbox_terminal/server/tests.rs +++ b/crates/buzz-acp/src/sandbox_terminal/server/tests.rs @@ -134,7 +134,7 @@ impl Fixture { std::fs::write( &executable, r#"#!/usr/bin/env python3 -import os, sys, signal +import os, sys, signal, json os.chdir(sys.argv[2]) with open('marker', 'w') as marker: marker.write('spawned') def end(*_): @@ -143,7 +143,10 @@ def end(*_): signal.signal(signal.SIGTERM, end) sys.stdin.readline() print('{"type":"ready"}', flush=True) -for _ in sys.stdin: pass +for line in sys.stdin: + packet = json.loads(line) + if packet.get('type') == 'data': + print(json.dumps({'type':'packet', 'packet':packet}), flush=True) end() "#, ) @@ -541,3 +544,180 @@ async fn harness_shutdown_propagates_unconfirmed_containment_and_task_panics() { tokio::spawn(async { panic!("deliberate terminal task panic") }); assert!(crate::sandbox_terminal::stop(Some(panic)).await.is_err()); } + +struct EngineRelay { + url: String, + inject: tokio::sync::mpsc::Sender, + events: tokio::sync::mpsc::Receiver, + ready: tokio::sync::oneshot::Receiver<()>, + task: tokio::task::JoinHandle<()>, +} + +async fn engine_relay(agent: PublicKey) -> EngineRelay { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + let (inject, mut injected) = tokio::sync::mpsc::channel::(16); + let (events_tx, events) = tokio::sync::mpsc::channel(16); + let (ready_tx, ready) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(socket).await.unwrap(); + ws.send(Message::Text( + json!(["AUTH", "terminal-engine-fixture"]) + .to_string() + .into(), + )) + .await + .unwrap(); + let mut ready = Some(ready_tx); + loop { + tokio::select! { + message=ws.next()=>{ + let Some(Ok(Message::Text(text)))=message else {break}; + let value:Value=serde_json::from_str(&text).unwrap(); + match value[0].as_str() { + Some("AUTH")=>{ + let event:Event=serde_json::from_value(value[1].clone()).unwrap(); + assert_eq!(event.pubkey,agent); + event.verify().unwrap(); + ws.send(Message::Text(json!(["OK",event.id,true,""]).to_string().into())).await.unwrap(); + } + Some("REQ")=>{if let Some(ready)=ready.take(){let _=ready.send(());}}, + Some("EVENT")=>{ + let event:Event=serde_json::from_value(value[1].clone()).unwrap(); + event.verify().unwrap(); + if event.kind.as_u16()==30624 { + let body:Value=serde_json::from_str(&event.content).unwrap(); + let receipt=if body["status"]=="accepted" {attach::CLAIM_OK_MESSAGE}else{"saved"}; + ws.send(Message::Text(json!(["OK",event.id,true,receipt]).to_string().into())).await.unwrap(); + } else {events_tx.send(event).await.unwrap();} + } + _=>{}, + } + } + Some(event)=injected.recv()=>{ + ws.send(Message::Text(json!(["EVENT",SUBSCRIPTION,event]).to_string().into())).await.unwrap(); + } + } + } + }); + EngineRelay { + url, + inject, + events, + ready, + task, + } +} + +#[tokio::test] +async fn production_engine_delivers_a_full_sdk_data_packet_through_worker_ipc() { + let f = Fixture::new(false).await; + let mut relay = engine_relay(f.config.scope.agent).await; + let mut config = f.config.clone(); + config.relay = relay.url.clone(); + let rest = f.rest.clone(); + let (shutdown_tx, shutdown) = watch::channel(()); + let service = tokio::spawn(serve(config, rest, shutdown)); + let bytes = vec![0xff; attach::MAX_DATA_BYTES]; + let journey = timeout(Duration::from_secs(5), async { + (&mut relay.ready).await.unwrap(); + relay.inject.send(f.open_event()).await.unwrap(); + loop { + let event = relay.events.recv().await.unwrap(); + let frame = attach::decrypt( + &f.requester, + f.config.scope.agent, + f.config.scope, + f.approval.id, + &event, + Timestamp::now().as_secs(), + ) + .unwrap(); + if frame.ack >= 1 { + break; + } + } + let data = attach::encrypt( + &f.requester, + f.config.scope.agent, + f.config.scope, + &Frame { + grant: f.approval.id, + seq: 2, + ack: 0, + packet: Packet::data(&bytes).unwrap(), + }, + ) + .unwrap(); + assert!( + data.content.len() > 32 * 1024, + "fixture did not cross the old encrypted-content guard" + ); + relay.inject.send(data).await.unwrap(); + loop { + let event = relay.events.recv().await.unwrap(); + let frame = attach::decrypt( + &f.requester, + f.config.scope.agent, + f.config.scope, + f.approval.id, + &event, + Timestamp::now().as_secs(), + ) + .unwrap(); + if matches!(frame.packet, Packet::Data { .. }) { + assert_eq!(frame.packet.bytes().unwrap(), bytes); + break; + } + } + }) + .await; + // Always confirm cleanup before asserting, including with the old guard + // restored by a mutation that deliberately prevents the packet's delivery. + shutdown_tx.send(()).unwrap(); + let stopped = timeout(Duration::from_secs(5), service).await; + relay.task.abort(); + assert!(stopped.unwrap().unwrap().is_ok()); + assert!( + journey.is_ok(), + "maximum SDK packet never reached/returned from the worker" + ); + let record = f.journal().records().unwrap().remove(0); + assert_eq!(record.bytes_in, attach::MAX_DATA_BYTES as u64); + assert_eq!(record.bytes_out, attach::MAX_DATA_BYTES as u64); +} + +#[test] +fn production_admission_rejects_oversize_before_crypto_without_spending_rate_budget() { + let (owner, agent) = (Keys::generate(), Keys::generate()); + let scope = Scope { + channel: Uuid::new_v4(), + thread: EventId::all_zeros(), + agent: agent.public_key(), + }; + let event = attach::encrypt( + &owner, + scope.agent, + scope, + &Frame { + grant: EventId::all_zeros(), + seq: u64::MAX, + ack: u64::MAX, + packet: Packet::data(&vec![0xff; attach::MAX_DATA_BYTES]).unwrap(), + }, + ) + .unwrap(); + assert!(event.content.len() > 32 * 1024); + assert!(event.content.len() <= buzz_core::observer::NIP44_MAX_CONTENT_LEN); + let mut oversized = event.clone(); + oversized.content = "z".repeat(buzz_core::observer::NIP44_MAX_CONTENT_LEN + 1); + let mut budget = Rate::default(); + // This deliberately has an invalid signature: the production gate only + // inspects size/admission and cannot invoke signature/decryption work. + assert!(!frame_admitted(&oversized, &mut budget, 0)); + for _ in 0..attach::MAX_FRAMES_PER_SECOND { + assert!(frame_admitted(&event, &mut budget, 0)); + } + assert!(!frame_admitted(&event, &mut budget, 0)); +} diff --git a/docs/sandbox-attach.md b/docs/sandbox-attach.md index b15dc68698e..96179844693 100644 --- a/docs/sandbox-attach.md +++ b/docs/sandbox-attach.md @@ -52,6 +52,8 @@ The journal caps records at 256 and each record at 16 KiB. One terminal runs per bound sandbox. Frames use recipient-encrypted NIP-44 payloads, four-frame windows, 16 KiB data packets, and a 64-frame-per-second budget. +Encrypted content uses the shared NIP-44 envelope bound of 87,472 bytes before cryptographic work. +The decoded data limit remains 16 KiB; padding and base64 make a full SDK packet exceed 32 KiB on the wire. Reliable empty heartbeats run every five seconds without resetting idle expiry. Five failed one-second retries close the transport; idle peer disappearance can therefore take about eleven seconds to detect. Actual input, resize, or output resets the five-minute idle deadline. From b426b84f3332b69a3839efe00c04221fc4853a3a Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 18:11:40 -0500 Subject: [PATCH 3/6] feat(cli): restore snapshots into bounded read-only mirrors Signed-off-by: Tee8z --- Cargo.lock | 1 + crates/buzz-cli/Cargo.toml | 6 +- crates/buzz-cli/src/sandbox_archive.rs | 11 + crates/buzz-cli/src/sandbox_archive/format.rs | 12 +- .../src/sandbox_archive/format/tests.rs | 10 +- .../buzz-cli/src/sandbox_archive/restore.rs | 69 ++ .../src/sandbox_archive/restore/platform.rs | 749 ++++++++++++++++++ .../sandbox_archive/restore/platform/tests.rs | 496 ++++++++++++ docs/sandbox-snapshot-archive.md | 55 ++ 9 files changed, 1401 insertions(+), 8 deletions(-) create mode 100644 crates/buzz-cli/src/sandbox_archive/restore.rs create mode 100644 crates/buzz-cli/src/sandbox_archive/restore/platform.rs create mode 100644 crates/buzz-cli/src/sandbox_archive/restore/platform/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 4b261a9031b..f8d57d06c83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1671,6 +1671,7 @@ dependencies = [ "nostr 0.44.7", "rand 0.10.1", "reqwest 0.13.4", + "rustix", "rustls", "serde", "serde_json", diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index c60f9c08a8f..8c7f91ba13f 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -86,6 +86,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" tar = "0.4.46" flate2 = "1" tokio-util = { workspace = true } +tempfile = "3" # Random number generation — full jitter for exponential backoff in with_retry rand = { workspace = true } @@ -93,7 +94,10 @@ rand = { workspace = true } [target.'cfg(unix)'.dependencies] # Safe local TTY handling; remote PTYs remain subordinate to buzz-acp. crossterm = { version = "0.29", default-features = false } -nix = { version = "0.31", default-features = false, features = ["fs"] } +nix = { version = "0.31", default-features = false, features = ["fs", "process", "signal"] } + +[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies] +rustix = { version = "1", features = ["fs", "process"] } [dev-dependencies] # Scratch files for channel-templates.json fixtures in tests diff --git a/crates/buzz-cli/src/sandbox_archive.rs b/crates/buzz-cli/src/sandbox_archive.rs index dc819de6f6e..38255ee12da 100644 --- a/crates/buzz-cli/src/sandbox_archive.rs +++ b/crates/buzz-cli/src/sandbox_archive.rs @@ -11,7 +11,9 @@ use buzz_sdk::sandbox_attach::snapshot::{ use serde::{Deserialize, Serialize}; mod format; +mod restore; pub use format::{inspect_archive, ArchivePlan}; +pub use restore::{check_git, extract, RestoreBudget, Restored}; /// Local archive refusal; file contents and scanner diagnostics are never included. #[derive(Debug, thiserror::Error)] @@ -19,6 +21,15 @@ pub enum ArchiveError { /// Cancellation completed after staging and child cleanup. #[error("snapshot extraction cancelled")] Cancelled, + /// The platform cannot guarantee atomic publication. + #[error("snapshot extraction requires Linux or macOS")] + Unsupported, + /// A bounded Git operation failed or Git is unavailable/denied. + #[error("snapshot Git operation failed: {0}")] + Git(&'static str), + /// Cleanup failed and its caller must retain this failure. + #[error("snapshot staging cleanup failed: {0}")] + Cleanup(std::io::Error), /// Filesystem access failed. #[error("archive file access failed: {0}")] Io(#[from] std::io::Error), diff --git a/crates/buzz-cli/src/sandbox_archive/format.rs b/crates/buzz-cli/src/sandbox_archive/format.rs index d6ea416a1f5..73375823294 100644 --- a/crates/buzz-cli/src/sandbox_archive/format.rs +++ b/crates/buzz-cli/src/sandbox_archive/format.rs @@ -125,6 +125,8 @@ pub(super) struct Item { pub path: String, pub size: u64, pub kind: Kind, + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub mode: u32, } /// Validated snapshot structure; parsing performs no filesystem writes or Git execution. /// Consumers must still validate imported Git objects and enforce local capacity limits. @@ -277,7 +279,13 @@ where if !fields.is_empty() { return Err(invalid("PAX link metadata on non-link")); } - let item = Item { path, size, kind }; + let item = Item { + path, + size, + kind, + #[cfg(any(target_os = "linux", target_os = "macos"))] + mode: entry.header().mode()?, + }; visit(item, &mut entry)?; std::io::copy(&mut entry, &mut std::io::sink())?; } @@ -616,4 +624,4 @@ impl ArchivePlan { } } #[cfg(test)] -mod tests; +pub(in crate::sandbox_archive) mod tests; diff --git a/crates/buzz-cli/src/sandbox_archive/format/tests.rs b/crates/buzz-cli/src/sandbox_archive/format/tests.rs index e1f3c313b09..c025ea814d8 100644 --- a/crates/buzz-cli/src/sandbox_archive/format/tests.rs +++ b/crates/buzz-cli/src/sandbox_archive/format/tests.rs @@ -3,7 +3,7 @@ use flate2::{write::GzEncoder, Compression}; use serde_json::{json, Value}; use std::{fs, io::Write, process::Command}; -fn git(repo: &Path, args: &[&str]) -> Vec { +pub(crate) fn git(repo: &Path, args: &[&str]) -> Vec { let output = Command::new("git") .arg("-C") .arg(repo) @@ -23,7 +23,7 @@ fn git(repo: &Path, args: &[&str]) -> Vec { ); output.stdout } -fn fixture(sha256: bool) -> (tempfile::TempDir, Value, Vec) { +pub(crate) fn fixture(sha256: bool) -> (tempfile::TempDir, Value, Vec) { let temp = tempfile::tempdir().unwrap(); let repo = temp.path(); git( @@ -65,7 +65,7 @@ fn fixture(sha256: bool) -> (tempfile::TempDir, Value, Vec) { let bytes = fs::read(bundle).unwrap(); (temp, manifest, bytes) } -fn header(path: &str, kind: u8, size: u64) -> tar::Header { +pub(crate) fn header(path: &str, kind: u8, size: u64) -> tar::Header { let mut h = tar::Header::new_ustar(); h.set_size(size); h.set_mode(0o644); @@ -75,7 +75,7 @@ fn header(path: &str, kind: u8, size: u64) -> tar::Header { h.set_cksum(); h } -fn packed(entries: Vec<(tar::Header, Vec)>) -> Vec { +pub(crate) fn packed(entries: Vec<(tar::Header, Vec)>) -> Vec { let gzip = GzEncoder::new(Vec::new(), Compression::fast()); let mut tar = tar::Builder::new(gzip); for (h, data) in entries { @@ -83,7 +83,7 @@ fn packed(entries: Vec<(tar::Header, Vec)>) -> Vec { } tar.into_inner().unwrap().finish().unwrap() } -fn entries(manifest: &Value, bundle: &[u8]) -> Vec<(tar::Header, Vec)> { +pub(crate) fn entries(manifest: &Value, bundle: &[u8]) -> Vec<(tar::Header, Vec)> { let data = serde_json::to_vec(manifest).unwrap(); vec![ ( diff --git a/crates/buzz-cli/src/sandbox_archive/restore.rs b/crates/buzz-cli/src/sandbox_archive/restore.rs new file mode 100644 index 00000000000..fc86bb0f954 --- /dev/null +++ b/crates/buzz-cli/src/sandbox_archive/restore.rs @@ -0,0 +1,69 @@ +//! Explicit cancellation means cancelling the token and awaiting completion. +use super::ArchiveError as Error; +use std::path::{Path, PathBuf}; +use tokio_util::sync::CancellationToken; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +use super::format; +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod platform; + +/// Explicit local limits; configure below the filesystem/container quota. +#[derive(Clone, Copy)] +pub struct RestoreBudget { + /// Maximum new scratch/import/copy/block usage for this operation. + pub work_bytes: u64, + /// Existing store usage plus this operation's peak must fit this limit. + pub store_bytes: u64, +} +impl Default for RestoreBudget { + fn default() -> Self { + Self { + work_bytes: 128 * 1024 * 1024, + store_bytes: 128 * 1024 * 1024, + } + } +} +/// Atomically published read-only mirror and its repository directories. +pub struct Restored { + /// Fresh directory which was never previously present. + pub directory: PathBuf, + /// Repository roots to open in the explorer for diffs/history. + pub repositories: Vec, +} + +/// Refuse unsupported platforms or unavailable/denied Git before requesting access. +/// Paseo host-tools=off keeps its existing Git refusal; this function does not bypass it. +pub async fn check_git() -> Result<(), Error> { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + platform::check_git().await + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + Err(Error::Unsupported) + } +} + +/// Restore authenticated plaintext to a fresh directory inside an explicit store. +/// +/// Cancel by cancelling `cancel`, then await this future to completion so it can +/// stop/reap Git children and remove staging. Do not abort/drop the future. +/// This local copy remains after grant expiry; remote expiry cannot erase delivered bytes. +pub async fn extract( + archive: Vec, + destination: &Path, + store_root: &Path, + budget: RestoreBudget, + cancel: CancellationToken, +) -> Result { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + platform::extract(archive, destination, store_root, budget, cancel).await + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = (archive, destination, store_root, budget, cancel); + Err(Error::Unsupported) + } +} diff --git a/crates/buzz-cli/src/sandbox_archive/restore/platform.rs b/crates/buzz-cli/src/sandbox_archive/restore/platform.rs new file mode 100644 index 00000000000..7a5789953c6 --- /dev/null +++ b/crates/buzz-cli/src/sandbox_archive/restore/platform.rs @@ -0,0 +1,749 @@ +use super::{ + format::{self, ArchivePlan as Plan, Kind}, + Error, RestoreBudget, Restored, +}; +use buzz_sdk::sandbox_attach::snapshot::{MAX_FILES, MAX_SOURCE_BYTES}; +use nix::fcntl::{Flock, FlockArg}; +use nix::sys::signal::{killpg, Signal}; +use nix::unistd::Pid; +use std::collections::{HashMap, HashSet}; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::os::unix::{ + fs::{MetadataExt, OpenOptionsExt, PermissionsExt}, + process::CommandExt, +}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::process::Command; +use tokio_util::sync::CancellationToken; + +fn invalid(message: &'static str) -> Error { + Error::Invalid(message) +} +fn cancelled(token: &CancellationToken) -> Result<(), Error> { + if token.is_cancelled() { + Err(Error::Cancelled) + } else { + Ok(()) + } +} + +async fn git( + directory: &Path, + args: &[&str], + input: Option>, + cap: usize, + timeout: Duration, + token: &CancellationToken, +) -> Result, Error> { + cancelled(token)?; + let mut command = Command::new("git"); + command + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_NO_REPLACE_OBJECTS", "1") + .env("GIT_GRAFT_FILE", "/dev/null") + .env("GIT_NO_LAZY_FETCH", "1") + .env("LC_ALL", "C") + .args([ + "-c", + "core.hooksPath=/dev/null", + "-c", + "core.fsmonitor=false", + "-c", + "core.attributesFile=/dev/null", + "-c", + "core.autocrlf=false", + "-c", + "core.logAllRefUpdates=false", + "-c", + "gc.auto=0", + "-c", + "maintenance.auto=false", + "-c", + "protocol.allow=never", + ]) + .arg("-C") + .arg(directory) + .args(args) + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + command.as_std_mut().process_group(0); + let mut child = command.spawn().map_err(|_| { + Error::Git("Git unavailable or denied; enable Paseo host tools for snapshots") + })?; + let id = child + .id() + .and_then(|n| i32::try_from(n).ok()) + .ok_or(Error::Git("missing Git process identity"))?; + let stdout = child + .stdout + .take() + .ok_or(Error::Git("missing Git output"))?; + let stdin = child.stdin.take(); + let operation = async { + let writer = async { + if let (Some(mut stdin), Some(input)) = (stdin, input) { + stdin.write_all(&input).await?; + stdin.shutdown().await?; + } + Ok::<_, std::io::Error>(()) + }; + let reader = async { + let mut bytes = Vec::new(); + stdout.take(cap as u64 + 1).read_to_end(&mut bytes).await?; + Ok::<_, std::io::Error>(bytes) + }; + let (_, bytes) = tokio::try_join!(writer, reader)?; + if bytes.len() > cap { + return Err(invalid("Git output exceeds snapshot budget")); + } + let pid = + rustix::process::Pid::from_raw(id).ok_or(Error::Git("invalid Git process identity"))?; + loop { + if let Some(status) = rustix::process::waitid( + rustix::process::WaitId::Pid(pid), + rustix::process::WaitIdOptions::EXITED + | rustix::process::WaitIdOptions::NOHANG + | rustix::process::WaitIdOptions::NOWAIT, + ) + .map_err(std::io::Error::from)? + { + if status.exit_status() != Some(0) { + return Err(Error::Git("fixed Git import/inventory operation refused")); + } + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + Ok(bytes) + }; + let result = tokio::select! {result=operation=>result,_=token.cancelled()=>Err(Error::Cancelled),_=tokio::time::sleep(timeout)=>Err(Error::Git("Git operation timed out"))}; + { + // The leader remains waitable (WNOWAIT), fencing PID/group reuse during teardown. + match killpg(Pid::from_raw(id), Signal::SIGKILL) { + Ok(()) | Err(nix::errno::Errno::ESRCH) => {} + Err(_) => return Err(Error::Git("Git process-tree stop failed")), + } + tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .map_err(|_| Error::Git("Git process-tree cleanup timed out"))??; + } + result +} + +pub(super) async fn check_git() -> Result<(), Error> { + let current = std::env::current_dir()?; + let output = git( + ¤t, + &["--version"], + None, + 4096, + Duration::from_secs(5), + &CancellationToken::new(), + ) + .await?; + let version = std::str::from_utf8(&output) + .ok() + .and_then(|s| s.strip_prefix("git version ")) + .and_then(|s| s.split_whitespace().next()) + .ok_or(Error::Git("Git version probe refused"))?; + let mut parts = version.split('.'); + let major = parts.next().and_then(|s| s.parse::().ok()); + let minor = parts.next().and_then(|s| s.parse::().ok()); + if !matches!((major,minor),(Some(major),Some(minor))if major>2||(major==2&&minor>=29)) { + return Err(Error::Git("snapshot extraction requires Git 2.29 or newer")); + } + Ok(()) +} + +struct Stage { + directory: tempfile::TempDir, + ready: tempfile::TempDir, + plan: Plan, + block: u64, + _lock: Flock, +} +fn usage(root: &Path, block: u64, token: &CancellationToken) -> Result { + let mut pending = vec![root.to_path_buf()]; + let mut total = 0u64; + let mut count = 0; + while let Some(path) = pending.pop() { + cancelled(token)?; + count += 1; + if count > MAX_FILES * 5 { + return Err(invalid("snapshot store entry budget exceeded")); + } + let meta = fs::symlink_metadata(&path)?; + total = total + .checked_add( + meta.len() + .max(meta.blocks().saturating_mul(512)) + .saturating_add(block), + ) + .ok_or_else(|| invalid("snapshot store usage overflow"))?; + if meta.is_dir() { + for entry in fs::read_dir(path)? { + if pending.len() + count >= MAX_FILES * 5 { + return Err(invalid("snapshot directory entry budget exceeded")); + } + pending.push(entry?.path()); + } + } + } + Ok(total) +} +fn projection(plan: &Plan, block: u64) -> Result { + let mut bytes = plan.bytes; + let mut directories = HashSet::new(); + let mut ref_directories = HashSet::new(); + let mut nodes = plan.files.len() as u64; + for stats in plan.inventory.values() { + bytes = bytes + .checked_add(stats.objects as u64 * 64) + .ok_or_else(|| invalid("snapshot index estimate overflow"))?; + nodes += 32 + stats.refs.len() as u64; + } + // Borrow prefixes instead of allocating a copy for every ancestor. + for path in plan.files.keys().map(PathBuf::as_path).chain( + plan.manifest + .repositories + .iter() + .map(|r| Path::new(&r.path)), + ) { + for parent in path.ancestors() { + directories.insert(parent); + if directories.len() as u64 + nodes > MAX_FILES as u64 * 5 { + return Err(invalid("snapshot directory budget exceeded")); + } + } + } + for (index, stats) in &plan.inventory { + for (_, name) in &stats.refs { + for parent in Path::new(name).ancestors().skip(1) { + ref_directories.insert((*index, parent)); + if (directories.len() + ref_directories.len()) as u64 + nodes > MAX_FILES as u64 * 5 + { + return Err(invalid("snapshot directory budget exceeded")); + } + } + } + } + for item in &plan.items { + if item.path.ends_with("/history.bundle") { + bytes = bytes + .checked_add(item.size) + .ok_or_else(|| invalid("snapshot pack estimate overflow"))?; + } + } + nodes += (directories.len() + ref_directories.len()) as u64; + // Git index paths/entries, transient metadata and operational reserve. + bytes + .checked_add(nodes.saturating_mul(block)) + .and_then(|n| n.checked_add(24 * 1024 * 1024)) + .ok_or_else(|| invalid("snapshot capacity estimate overflow")) +} +fn cleanup(root: &Path) -> Result<(), std::io::Error> { + match fs::symlink_metadata(root) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + } + let mut pending = vec![root.to_path_buf()]; + let mut count = 0usize; + while let Some(path) = pending.pop() { + count += 1; + if count > MAX_FILES * 5 { + return Err(std::io::Error::other( + "snapshot cleanup entry budget exceeded", + )); + } + let meta = fs::symlink_metadata(&path)?; + if meta.is_dir() { + fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?; + for entry in fs::read_dir(&path)? { + if pending.len() + count >= MAX_FILES * 5 { + return Err(std::io::Error::other( + "snapshot cleanup entry budget exceeded", + )); + } + pending.push(entry?.path()); + } + } + } + fs::remove_dir_all(root) +} +fn prepare( + bytes: Vec, + destination: PathBuf, + store: PathBuf, + budget: RestoreBudget, + token: CancellationToken, +) -> Result { + cancelled(&token)?; + if budget.work_bytes == 0 + || budget.store_bytes == 0 + || budget.work_bytes > 2 * 1024 * 1024 * 1024 + || budget.store_bytes > 2 * 1024 * 1024 * 1024 + { + return Err(invalid("invalid snapshot work/store budget")); + } + let lock_file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .custom_flags(nix::libc::O_NOFOLLOW) + .open(store.join(".buzz-snapshot.lock"))?; + if !lock_file.metadata()?.is_file() { + return Err(invalid("snapshot store lock is not regular")); + } + let lock = Flock::lock(lock_file, FlockArg::LockExclusiveNonblock) + .map_err(|_| invalid("snapshot store is busy"))?; + let plan = format::inspect_archive(&bytes, &token)?; + let info = rustix::fs::statvfs(&store).map_err(std::io::Error::from)?; + let block = info.f_frsize.max(512); + let needed = projection(&plan, block)?; + let available = info.f_bavail.saturating_mul(info.f_frsize); + if needed > budget.work_bytes + || needed > available + || usage(&store, block, &token)?.saturating_add(needed) > budget.store_bytes + { + return Err(invalid( + "snapshot staging/store capacity exceeds configured budget", + )); + } + let parent = destination + .parent() + .ok_or_else(|| invalid("missing snapshot parent"))?; + let directory = tempfile::Builder::new() + .prefix(".buzz-snapshot-work-") + .tempdir_in(parent)?; + let ready = tempfile::Builder::new() + .prefix(".buzz-snapshot-ready-") + .tempdir_in(parent)?; + let mut roots = HashSet::new(); + for repo in &plan.manifest.repositories { + let path = ready.path().join(&repo.path); + fs::create_dir_all(&path)?; + let meta = fs::metadata(path)?; + if !roots.insert((meta.dev(), meta.ino())) { + return Err(invalid( + "snapshot repository paths alias on this filesystem", + )); + } + } + let reversed: HashMap<_, _> = plan + .files + .iter() + .map(|(path, name)| (name.as_str(), path)) + .collect(); + let result = format::walk(&bytes, &token, |item, reader| { + cancelled(&token)?; + if item.kind != Kind::File { + return Ok(()); + } + let target = if let Some(path) = reversed.get(item.path.as_str()) { + Some(ready.path().join(path)) + } else if item.path.ends_with("/history.bundle") { + Some(directory.path().join(&item.path)) + } else { + None + }; + if let Some(target) = target { + let parent = target + .parent() + .ok_or_else(|| invalid("missing snapshot file parent"))?; + fs::create_dir_all(parent)?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&target)?; + std::io::copy(reader, &mut file)?; + file.flush()?; + if reversed.contains_key(item.path.as_str()) { + fs::set_permissions( + &target, + fs::Permissions::from_mode(if item.mode & 0o111 != 0 { 0o500 } else { 0o400 }), + )?; + } + } + Ok(()) + }); + if let Err(error) = result { + cleanup(directory.path()).map_err(Error::Cleanup)?; + cleanup(ready.path()).map_err(Error::Cleanup)?; + return Err(error); + } + Ok(Stage { + directory, + ready, + plan, + block, + _lock: lock, + }) +} + +async fn import(stage: &Stage, token: &CancellationToken) -> Result<(), Error> { + let mut tracked = 0usize; + for (index, repo) in stage.plan.manifest.repositories.iter().enumerate() { + cancelled(token)?; + let root = stage.ready.path().join(&repo.path); + let bundle = stage + .directory + .path() + .join(format!("snapshot/repositories/{index}/history.bundle")); + let stats = stage + .plan + .inventory + .get(&index) + .ok_or_else(|| invalid("missing snapshot inventory"))?; + if stats + .refs + .iter() + .any(|(oid, _)| oid.bytes().all(|b| b == b'0')) + { + return Err(invalid("null snapshot reference object")); + } + let object_format = if repo.head.len() == 64 { + "--object-format=sha256" + } else { + "--object-format=sha1" + }; + git( + &root, + &["init", "--template=", object_format], + None, + 4096, + Duration::from_secs(60), + token, + ) + .await?; + let bundle = bundle + .to_str() + .ok_or_else(|| invalid("non-UTF8 local bundle path"))?; + git( + &root, + &["bundle", "unbundle", bundle], + None, + 8 * 1024 * 1024, + Duration::from_secs(60), + token, + ) + .await?; + + let mut updates = Vec::new(); + for (oid, name) in &stats.refs { + if name != "HEAD" { + updates.extend_from_slice(format!("create {name}\0{oid}\0").as_bytes()); + } + } + git( + &root, + &["update-ref", "--stdin", "-z"], + Some(updates), + 4096, + Duration::from_secs(60), + token, + ) + .await?; + git( + &root, + &["update-ref", "--no-deref", "HEAD", &repo.head], + None, + 4096, + Duration::from_secs(60), + token, + ) + .await?; + if !repo.shallow.is_empty() { + fs::write( + root.join(".git/shallow"), + format!("{}\n", repo.shallow.join("\n")), + )?; + } + git( + &root, + &[ + "fsck", + "--full", + "--strict", + "--no-reflogs", + "--no-dangling", + ], + None, + 1024 * 1024, + Duration::from_secs(60), + token, + ) + .await?; + let objects = git( + &root, + &[ + "cat-file", + "--batch-all-objects", + "--batch-check=%(objectsize)", + ], + None, + 4 * 1024 * 1024, + Duration::from_secs(60), + token, + ) + .await?; + let sizes: Vec = std::str::from_utf8(&objects) + .map_err(|_| invalid("invalid imported object inventory"))? + .lines() + .map(|s| { + s.parse() + .map_err(|_| invalid("invalid imported object size")) + }) + .collect::>()?; + let expanded = sizes.iter().try_fold(0u64, |sum, n| { + sum.checked_add(*n) + .ok_or_else(|| invalid("imported object size overflow")) + })?; + if sizes.len() != stats.objects as usize + || expanded != stats.expanded_bytes + || expanded > MAX_SOURCE_BYTES + { + return Err(invalid("imported objects differ from bounded preflight")); + } + let paths = git( + &root, + &["ls-tree", "-rz", "--name-only", "HEAD"], + None, + 8 * 1024 * 1024, + Duration::from_secs(60), + token, + ) + .await?; + let paths: Vec<_> = paths.split(|b| *b == 0).filter(|s| !s.is_empty()).collect(); + tracked += paths.len(); + if tracked > MAX_FILES { + return Err(invalid("snapshot tracked-path budget exceeded")); + } + let mut skipped = Vec::new(); + for raw in paths { + let path = + std::str::from_utf8(raw).map_err(|_| invalid("non-UTF8 tracked snapshot path"))?; + if format::excluded(&format::safe(path)?) { + skipped.extend_from_slice(raw); + skipped.push(0); + } + } + git( + &root, + &["read-tree", "HEAD"], + None, + 4096, + Duration::from_secs(60), + token, + ) + .await?; + if !skipped.is_empty() { + git( + &root, + &["update-index", "--skip-worktree", "-z", "--stdin"], + Some(skipped), + 4096, + Duration::from_secs(60), + token, + ) + .await?; + } + } + Ok(()) +} +fn finish( + stage: Stage, + destination: PathBuf, + store: PathBuf, + budget: RestoreBudget, + token: CancellationToken, +) -> Result { + let result = (|| { + cancelled(&token)?; + let by_name: HashMap<_, _> = stage + .plan + .items + .iter() + .map(|item| (item.path.as_str(), item)) + .collect(); + for path in stage.plan.links.keys() { + let index = stage + .plan + .files + .get(path) + .and_then(|name| by_name.get(name.as_str()).copied()) + .ok_or_else(|| invalid("missing link entry"))?; + let Kind::Link(original) = &index.kind else { + return Err(invalid("invalid link entry")); + }; + let output = stage.ready.path().join(path); + fs::create_dir_all( + output + .parent() + .ok_or_else(|| invalid("missing link parent"))?, + )?; + std::os::unix::fs::symlink(original, output)?; + } + let mut dirs = Vec::new(); + let mut pending = vec![stage.ready.path().to_path_buf()]; + let mut count = 0; + while let Some(path) = pending.pop() { + cancelled(&token)?; + count += 1; + if count > MAX_FILES * 5 { + return Err(invalid("snapshot final entry budget exceeded")); + } + let metadata = fs::symlink_metadata(&path)?; + if metadata.is_dir() { + dirs.push(path.clone()); + for entry in fs::read_dir(path)? { + if pending.len() + count >= MAX_FILES * 5 { + return Err(invalid("snapshot directory entry budget exceeded")); + } + pending.push(entry?.path()); + } + } else if metadata.is_file() { + fs::set_permissions( + path, + fs::Permissions::from_mode(if metadata.mode() & 0o111 != 0 { + 0o500 + } else { + 0o400 + }), + )?; + } + } + if usage(stage.directory.path(), stage.block, &token)?.saturating_add(usage( + stage.ready.path(), + stage.block, + &token, + )?) > budget.work_bytes + { + return Err(invalid("snapshot import exceeded work budget")); + } + cleanup(stage.directory.path()).map_err(Error::Cleanup)?; + for dir in dirs.iter().rev() { + fs::set_permissions(dir, fs::Permissions::from_mode(0o500))?; + } + if usage(&store, stage.block, &token)? > budget.store_bytes { + return Err(invalid("snapshot store budget changed during restore")); + } + cancelled(&token)?; + rustix::fs::renameat_with( + rustix::fs::CWD, + stage.ready.path(), + rustix::fs::CWD, + &destination, + rustix::fs::RenameFlags::NOREPLACE, + ) + .map_err(std::io::Error::from)?; + Ok(Restored { + repositories: stage + .plan + .manifest + .repositories + .iter() + .map(|r| destination.join(&r.path)) + .collect(), + directory: destination, + }) + })(); + match result { + Ok(restored) => { + // Publication is the last fallible action; the scratch tree was removed first. + let _ = stage.ready.keep(); + Ok(restored) + } + Err(error) => { + cleanup(stage.directory.path()).map_err(Error::Cleanup)?; + cleanup(stage.ready.path()).map_err(Error::Cleanup)?; + Err(error) + } + } +} + +async fn restore( + archive: Vec, + destination: &Path, + store_root: &Path, + budget: RestoreBudget, + cancel: CancellationToken, +) -> Result { + check_git().await?; + cancelled(&cancel)?; + let store = store_root.canonicalize()?; + let parent = destination + .parent() + .ok_or_else(|| invalid("missing destination parent"))? + .canonicalize()?; + let name = destination + .file_name() + .ok_or_else(|| invalid("invalid snapshot destination"))?; + let destination = parent.join(name); + if !parent.starts_with(&store) { + return Err(invalid( + "snapshot destination must be fresh inside its store", + )); + } + match fs::symlink_metadata(&destination) { + Ok(_) => return Err(invalid("snapshot destination already exists")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let token = cancel.clone(); + let target = destination.clone(); + let base = store.clone(); + let stage = tokio::task::spawn_blocking(move || prepare(archive, target, base, budget, token)) + .await + .map_err(|_| invalid("snapshot prepare worker failed"))??; + if let Err(error) = import(&stage, &cancel).await { + let path = stage.directory.path().to_path_buf(); + let ready = stage.ready.path().to_path_buf(); + tokio::task::spawn_blocking(move || { + cleanup(&path)?; + cleanup(&ready) + }) + .await + .map_err(|_| invalid("snapshot cleanup worker failed"))? + .map_err(Error::Cleanup)?; + return Err(error); + } + tokio::task::spawn_blocking(move || finish(stage, destination, store, budget, cancel)) + .await + .map_err(|_| invalid("snapshot publish worker failed"))? +} + +pub(super) async fn extract( + archive: Vec, + destination: &Path, + store_root: &Path, + budget: RestoreBudget, + cancel: CancellationToken, +) -> Result { + let token = cancel.child_token(); + let work = restore(archive, destination, store_root, budget, token.clone()); + tokio::pin!(work); + tokio::select! {result=&mut work=>result,_=tokio::time::sleep(Duration::from_secs(180))=>{ + token.cancel();match work.await{Ok(restored)=>Ok(restored),Err(Error::Cancelled)=>Err(invalid("snapshot restore timed out")),Err(error)=>Err(error)} + }} +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-cli/src/sandbox_archive/restore/platform/tests.rs b/crates/buzz-cli/src/sandbox_archive/restore/platform/tests.rs new file mode 100644 index 00000000000..695134762e8 --- /dev/null +++ b/crates/buzz-cli/src/sandbox_archive/restore/platform/tests.rs @@ -0,0 +1,496 @@ +use super::*; +use crate::sandbox_archive::format::tests as fixture; +use serde_json::json; + +fn archive() -> Vec { + let (_source, manifest, bundle) = fixture::fixture(false); + fixture::packed(fixture::entries(&manifest, &bundle)) +} +fn no_staging(store: &Path) { + assert!(!fs::read_dir(store).unwrap().any(|entry| { + let entry = entry.unwrap(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + name.starts_with(".buzz-snapshot-work-") || name.starts_with(".buzz-snapshot-ready-") + })); +} +#[tokio::test] +async fn real_git_restore_keeps_history_and_diffs_while_omitting_private_working_paths() { + for sha256 in [false, true] { + let (source, mut manifest, _) = fixture::fixture(sha256); + let previous = manifest["repositories"][0]["head"] + .as_str() + .unwrap() + .to_owned(); + fs::write(source.path().join("deleted.txt"), "gone from worktree\n").unwrap(); + fixture::git(source.path(), &["add", "deleted.txt"]); + fixture::git( + source.path(), + &[ + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "deletion fixture", + ], + ); + let bundle = source.path().join("history.bundle"); + fs::remove_file(&bundle).unwrap(); + fixture::git( + source.path(), + &[ + "bundle", + "create", + bundle.to_str().unwrap(), + "--all", + "HEAD", + ], + ); + manifest["repositories"][0]["head"] = json!(String::from_utf8(fixture::git( + source.path(), + &["rev-parse", "HEAD"] + )) + .unwrap() + .trim()); + manifest["repositories"][0]["files"] = json!(["tracked.txt", "inside-link"]); + let mut items = fixture::entries(&manifest, &fs::read(bundle).unwrap()); + items[2].0.set_mode(0o755); + items[2].0.set_cksum(); + let mut link = fixture::header("snapshot/repositories/0/files/inside-link", b'2', 0); + link.set_link_name("tracked.txt").unwrap(); + link.set_cksum(); + items.push((link, vec![])); + let archive = fixture::packed(items); + let store = tempfile::tempdir().unwrap(); + let destination = store.path().join("mirror"); + let restored = extract( + archive, + &destination, + store.path(), + RestoreBudget::default(), + CancellationToken::new(), + ) + .await + .unwrap(); + let repo = &restored.repositories[0]; + assert_eq!(restored.directory, destination); + assert_eq!(fs::read(repo.join("tracked.txt")).unwrap(), b"dirty\n"); + assert!(!repo.join(".env.example").exists()); + assert!( + String::from_utf8(fixture::git(repo, &["show", "HEAD:.env.example"])) + .unwrap() + .contains("TEMPLATE=public") + ); + assert_eq!( + fixture::git(repo, &["diff", "--name-status"]), + b"D\tdeleted.txt\nM\ttracked.txt\n" + ); + assert_eq!( + fixture::git(repo, &["rev-parse", "refs/heads/local-only"]), + format!("{previous}\n").as_bytes() + ); + for path in [ + repo.clone(), + repo.join("tracked.txt"), + repo.join(".git/index"), + ] { + assert_eq!(fs::metadata(path).unwrap().mode() & 0o222, 0); + } + assert_eq!( + fs::read_link(repo.join("inside-link")).unwrap(), + Path::new("tracked.txt") + ); + assert_eq!(fs::read(repo.join("inside-link")).unwrap(), b"dirty\n"); + assert_eq!( + fs::metadata(repo.join("tracked.txt")).unwrap().mode() & 0o777, + 0o500 + ); + no_staging(store.path()); + cleanup(&destination).unwrap(); + } +} +#[tokio::test] +async fn capacity_existing_store_and_fresh_destination_are_enforced() { + let store = tempfile::tempdir().unwrap(); + let destination = store.path().join("mirror"); + let small = RestoreBudget { + work_bytes: 1024 * 1024, + store_bytes: 128 * 1024 * 1024, + }; + assert!(extract( + archive(), + &destination, + store.path(), + small, + CancellationToken::new() + ) + .await + .is_err()); + assert!(!destination.exists()); + no_staging(store.path()); + fs::write(store.path().join("existing"), vec![1u8; 8 * 1024 * 1024]).unwrap(); + let tight = RestoreBudget { + work_bytes: 32 * 1024 * 1024, + store_bytes: 32 * 1024 * 1024, + }; + assert!(extract( + archive(), + &destination, + store.path(), + tight, + CancellationToken::new() + ) + .await + .is_err()); + no_staging(store.path()); + fs::create_dir(&destination).unwrap(); + assert!(extract( + archive(), + &destination, + store.path(), + RestoreBudget::default(), + CancellationToken::new() + ) + .await + .is_err()); + assert!(destination.is_dir()); + assert_eq!(fs::read_dir(destination).unwrap().count(), 0); +} +#[tokio::test] +async fn atomic_noreplace_closes_destination_creation_race() { + let store = tempfile::tempdir().unwrap(); + let destination = store.path().join("mirror"); + let token = CancellationToken::new(); + let stage = prepare( + archive(), + destination.clone(), + store.path().into(), + RestoreBudget::default(), + token.clone(), + ) + .unwrap(); + import(&stage, &token).await.unwrap(); + fs::create_dir(&destination).unwrap(); + assert!(finish( + stage, + destination.clone(), + store.path().into(), + RestoreBudget::default(), + token + ) + .is_err()); + assert_eq!(fs::read_dir(destination).unwrap().count(), 0); + no_staging(store.path()); +} +#[tokio::test] +async fn real_shallow_bundle_needs_its_declared_missing_parent_boundary() { + let (source, mut manifest, _) = fixture::fixture(false); + fs::write(source.path().join("tracked.txt"), "second\n").unwrap(); + fixture::git(source.path(), &["add", "tracked.txt"]); + fixture::git( + source.path(), + &["-c", "commit.gpgsign=false", "commit", "-m", "second"], + ); + let cloned = tempfile::tempdir().unwrap(); + fixture::git( + cloned.path(), + &[ + "clone", + "--template=", + "--depth=1", + &format!("file://{}", source.path().display()), + "clone", + ], + ); + let repo = cloned.path().join("clone"); + let head = String::from_utf8(fixture::git(&repo, &["rev-parse", "HEAD"])) + .unwrap() + .trim() + .to_owned(); + let bundle = repo.join("history.bundle"); + fixture::git( + &repo, + &[ + "bundle", + "create", + bundle.to_str().unwrap(), + "--all", + "HEAD", + ], + ); + let bundle = fs::read(bundle).unwrap(); + manifest["repositories"][0]["head"] = json!(head); + manifest["repositories"][0]["shallow"] = json!([head]); + for declared in [false, true] { + let store = tempfile::tempdir().unwrap(); + let destination = store.path().join("mirror"); + let mut current = manifest.clone(); + if !declared { + current["repositories"][0]["shallow"] = json!([]); + } + let result = extract( + fixture::packed(fixture::entries(¤t, &bundle)), + &destination, + store.path(), + RestoreBudget::default(), + CancellationToken::new(), + ) + .await; + assert_eq!(result.is_ok(), declared); + no_staging(store.path()); + if declared { + assert!(destination.join("workspace/.git/shallow").is_file()); + cleanup(&destination).unwrap(); + } + } +} +#[tokio::test] +async fn cancellation_cleans_staging_at_prepare_import_and_publish_boundaries() { + let store = tempfile::tempdir().unwrap(); + let destination = store.path().join("mirror"); + let token = CancellationToken::new(); + token.cancel(); + assert!(matches!( + extract( + archive(), + &destination, + store.path(), + RestoreBudget::default(), + token + ) + .await, + Err(Error::Cancelled) + )); + for imported in [false, true] { + let token = CancellationToken::new(); + let stage = prepare( + archive(), + destination.clone(), + store.path().into(), + RestoreBudget::default(), + token.clone(), + ) + .unwrap(); + if imported { + import(&stage, &token).await.unwrap(); + } + token.cancel(); + if imported { + assert!(matches!( + finish( + stage, + destination.clone(), + store.path().into(), + RestoreBudget::default(), + token + ), + Err(Error::Cancelled) + )); + } else { + assert!(matches!( + import(&stage, &token).await, + Err(Error::Cancelled) + )); + cleanup(stage.directory.path()).unwrap(); + cleanup(stage.ready.path()).unwrap(); + } + assert!(!destination.exists()); + no_staging(store.path()); + } +} +#[test] +fn git_process_boundary_is_exercised_in_isolated_child_environments() { + let temp = tempfile::tempdir().unwrap(); + let executable = std::env::current_exe().unwrap(); + for mode in ["cancel", "exit", "deny", "flood"] { + let script = temp.path().join("git"); + let marker = temp.path().join("child"); + let _ = fs::remove_file(&marker); + let body = match mode { + "deny" => "exit 126\n".to_owned(), + "flood" => "printf '%08192d' 0\n".to_owned(), + _ => format!( + "while :; do :; done >/dev/null 2>&1 &\necho $! > '{}'\n{}\n", + marker.display(), + if mode == "cancel" { "wait" } else { "exit 0" } + ), + }; + fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap(); + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); + let output = std::process::Command::new(&executable) + .args([ + "--exact", + "sandbox_archive::restore::platform::tests::git_process_driver", + "--ignored", + ]) + .env("PATH", temp.path()) + .env("BUZZ_TEST_GIT_MODE", mode) + .output() + .unwrap(); + assert!( + output.status.success(), + "{mode}: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + if marker.exists() { + let pid = fs::read_to_string(&marker).unwrap(); + let path = format!("/proc/{}/stat", pid.trim()); + #[cfg(target_os = "linux")] + if let Ok(stat) = fs::read_to_string(path) { + assert_eq!( + stat.split(')').nth(1).unwrap().split_whitespace().next(), + Some("Z") + ); + } + } + } +} +#[tokio::test] +#[ignore = "isolated child fixture for the automatic Git process boundary parent test"] +async fn git_process_driver() { + let mode = std::env::var("BUZZ_TEST_GIT_MODE").unwrap(); + let token = CancellationToken::new(); + if mode == "cancel" { + let cancel = token.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + cancel.cancel(); + }); + } + let result = git( + &std::env::current_dir().unwrap(), + &["fixture"], + None, + 4096, + Duration::from_secs(3), + &token, + ) + .await; + match mode.as_str() { + "cancel" => assert!(matches!(result, Err(Error::Cancelled))), + "exit" => { + assert!(result.is_ok()); + } + "deny" | "flood" => assert!(result.is_err()), + _ => panic!("unknown fixture"), + } +} + +#[tokio::test] +#[ignore = "requires exact hardened producer archive in BUZZ_SNAPSHOT_ARCHIVE_FIXTURE"] +async fn exact_packaged_archive_restores_history_and_working_diff() { + let bytes = fs::read(std::env::var("BUZZ_SNAPSHOT_ARCHIVE_FIXTURE").unwrap()).unwrap(); + let store = tempfile::tempdir().unwrap(); + let destination = store.path().join("mirror"); + let restored = extract( + bytes, + &destination, + store.path(), + RestoreBudget::default(), + CancellationToken::new(), + ) + .await + .unwrap(); + let repo = &restored.repositories[0]; + assert_eq!(fs::read(repo.join("tracked.txt")).unwrap(), b"dirty\n"); + assert!(!repo.join(".env.example").exists()); + assert_eq!( + fs::read_link(repo.join("inside-link")).unwrap(), + Path::new("tracked.txt") + ); + assert_eq!( + fixture::git(repo, &["diff", "--name-status"]), + b"M\ttracked.txt\n" + ); + assert_eq!( + fixture::git(repo, &["rev-parse", "refs/heads/local-only"]), + fixture::git(repo, &["rev-parse", "HEAD"]) + ); + assert_eq!(fs::metadata(&destination).unwrap().mode() & 0o777, 0o500); + no_staging(store.path()); + cleanup(&destination).unwrap(); +} + +#[test] +fn empty_deep_repository_directories_are_reserved_before_writes() { + let (_source, mut manifest, bundle) = fixture::fixture(false); + let mut records = Vec::new(); + let mut items = Vec::new(); + for index in 0..64 { + let mut repo = manifest["repositories"][0].clone(); + repo["path"] = json!(format!("repo-{index}/{}", "a/".repeat(126)) + "a"); + repo["files"] = json!([]); + records.push(repo); + items.push(( + fixture::header( + &format!("snapshot/repositories/{index}/history.bundle"), + b'0', + bundle.len() as u64, + ), + bundle.clone(), + )); + } + manifest["repositories"] = json!(records); + let data = serde_json::to_vec(&manifest).unwrap(); + items.push(( + fixture::header("snapshot/manifest.json", b'0', data.len() as u64), + data, + )); + let bytes = fixture::packed(items); + let token = CancellationToken::new(); + let plan = format::inspect_archive(&bytes, &token).unwrap(); + assert!(projection(&plan, 4096).unwrap() > 48 * 1024 * 1024); + let store = tempfile::tempdir().unwrap(); + let budget = RestoreBudget { + work_bytes: 48 * 1024 * 1024, + store_bytes: 128 * 1024 * 1024, + }; + assert!(prepare( + bytes, + store.path().join("mirror"), + store.path().into(), + budget, + token + ) + .is_err()); + no_staging(store.path()); +} + +#[tokio::test] +async fn malformed_bundle_ref_headers_cannot_create_undeclared_refs() { + let (_source, manifest, bundle) = fixture::fixture(false); + let end = bundle.windows(2).position(|s| s == b"\n\n").unwrap(); + let header = std::str::from_utf8(&bundle[..end]).unwrap(); + let oid = manifest["repositories"][0]["head"].as_str().unwrap(); + for (value, name) in [ + ("0".repeat(40), format!("refs/heads/injected {oid}")), + (oid.to_owned(), "refs/heads/bad..name".into()), + ("0".repeat(40), "refs/heads/null".into()), + ] { + let mut malformed = format!("{header}\n{value} {name}\n\n").into_bytes(); + malformed.extend_from_slice(&bundle[end + 2..]); + let store = tempfile::tempdir().unwrap(); + let destination = store.path().join("mirror"); + let result = extract( + fixture::packed(fixture::entries(&manifest, &malformed)), + &destination, + store.path(), + RestoreBudget::default(), + CancellationToken::new(), + ) + .await; + if name == "refs/heads/null" { + assert!(matches!( + result, + Err(Error::Invalid("null snapshot reference object")) + )); + } else if name.contains(' ') { + assert!(matches!(result, Err(Error::Bundle(_)))); + } else { + assert!(matches!(result, Err(Error::Git(_)))); + } + assert!(!destination.exists()); + no_staging(store.path()); + } +} diff --git a/docs/sandbox-snapshot-archive.md b/docs/sandbox-snapshot-archive.md index e525cc1ae76..83310f72c12 100644 --- a/docs/sandbox-snapshot-archive.md +++ b/docs/sandbox-snapshot-archive.md @@ -52,3 +52,58 @@ symlink ancestors to reject cycles and excessive chains. A nested repository owns a copied file when both its own bundle and an outer repository name the same physical path. Consumers must defer creating links until all regular files and Git metadata have been written. + +## Read-only restore + +`buzz_cli::sandbox_archive::check_git().await` checks platform support and Git +2.29 or newer before a caller publishes a snapshot request. Linux and macOS are +supported. Paseo requires `host-tools=on` for restore and explorer Git commands. +The probe preserves the Git-denying PATH wrapper when host tools are off. + +`extract` owns the authenticated archive bytes and accepts a fresh destination, +store root, `RestoreBudget`, and cancellation token. The destination parent must +already exist inside the store. Existing destinations, including empty +directories, are refused. The output lists repository roots for explorer +history and diffs. + +```mermaid +flowchart LR + A[Validate archive] --> B[Reserve storage] + B --> C[Stage files and bundles] + C --> D[Import and verify Git] + D --> E[Create safe links] + E --> F[Freeze permissions] + F --> G[Publish fresh directory] +``` + +Restore serializes operations within a store. It charges existing store usage, +new file and directory blocks, temporary bundles, imported packs, indexes and +an operational reserve. Both explicit work/store budgets and actual filesystem +free space must permit the operation. Filesystem free space does not reveal a +Kubernetes emptyDir quota. Configure budgets below that quota. The defaults +allow 128 MiB for the operation and the total store; explicit limits cannot +exceed 2 GiB each. + +Git imports run with fixed arguments and no network, hooks, templates, +replacement refs, lazy fetch or external configuration. Pack preflight happens +before import. Full Git integrity checks and actual object inventories must +match the preflight before index creation. Ref creation uses [Git's NUL-delimited transaction format](https://git-scm.com/docs/git-update-ref) and rejects null object IDs. Restore reads HEAD into the index +without checking out files. It marks excluded tracked paths skip-worktree, so +omitted private files do not appear as deletions. Genuine working-tree +deletions remain visible in diffs. Local branches, tags and declared shallow +boundaries are preserved without fetching unavailable ancestors. + +Each Git operation has bounded output and a 60-second deadline. Restore has a +180-second deadline. Process-group teardown retains the leader until after the +stop signal, preventing process-ID reuse during cleanup. Scratch directories +are private and beside the destination. Safe symlinks are created after files +and Git metadata. Files and directories become owner-only and read-only before atomic +publication with no replacement of an existing destination. + +Cancel by cancelling the token, then await `extract` to completion. Do not abort +or drop its future. The function stops and reaps Git children and removes +staging before returning cancellation. Failures leave the destination absent; +a concurrent creator keeps its own destination. Cleanup failures propagate. +Once publication succeeds, the local copy remains available after remote grant +expiry. Expiry prevents new transfer and receipt use; it cannot erase bytes +already delivered to an authorized reader. From 5876c9bd3e689f0956e7374b0ab9060da106d562 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 18:28:18 -0500 Subject: [PATCH 4/6] fix(cli): preserve Git executable modes in snapshot mirrors Signed-off-by: Tee8z --- .../src/sandbox_archive/restore/platform.rs | 4 +-- .../sandbox_archive/restore/platform/tests.rs | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/buzz-cli/src/sandbox_archive/restore/platform.rs b/crates/buzz-cli/src/sandbox_archive/restore/platform.rs index 7a5789953c6..f28d53ad73d 100644 --- a/crates/buzz-cli/src/sandbox_archive/restore/platform.rs +++ b/crates/buzz-cli/src/sandbox_archive/restore/platform.rs @@ -378,7 +378,7 @@ fn prepare( if reversed.contains_key(item.path.as_str()) { fs::set_permissions( &target, - fs::Permissions::from_mode(if item.mode & 0o111 != 0 { 0o500 } else { 0o400 }), + fs::Permissions::from_mode(if item.mode & 0o100 != 0 { 0o500 } else { 0o400 }), )?; } } @@ -622,7 +622,7 @@ fn finish( } else if metadata.is_file() { fs::set_permissions( path, - fs::Permissions::from_mode(if metadata.mode() & 0o111 != 0 { + fs::Permissions::from_mode(if metadata.mode() & 0o100 != 0 { 0o500 } else { 0o400 diff --git a/crates/buzz-cli/src/sandbox_archive/restore/platform/tests.rs b/crates/buzz-cli/src/sandbox_archive/restore/platform/tests.rs index 695134762e8..d677e8e7ed0 100644 --- a/crates/buzz-cli/src/sandbox_archive/restore/platform/tests.rs +++ b/crates/buzz-cli/src/sandbox_archive/restore/platform/tests.rs @@ -494,3 +494,36 @@ async fn malformed_bundle_ref_headers_cannot_create_undeclared_refs() { no_staging(store.path()); } } + +#[tokio::test] +async fn nonowner_execute_bits_do_not_invent_git_mode_changes() { + let (source, manifest, bundle) = fixture::fixture(false); + fs::set_permissions( + source.path().join("tracked.txt"), + fs::Permissions::from_mode(0o654), + ) + .unwrap(); + assert!(fixture::git(source.path(), &["diff", "--name-status"]).is_empty()); + let mut items = fixture::entries(&manifest, &bundle); + items[2].1 = b"clean\n".to_vec(); + items[2].0.set_mode(0o654); + items[2].0.set_cksum(); + let store = tempfile::tempdir().unwrap(); + let destination = store.path().join("mirror"); + let restored = extract( + fixture::packed(items), + &destination, + store.path(), + RestoreBudget::default(), + CancellationToken::new(), + ) + .await + .unwrap(); + let repo = &restored.repositories[0]; + assert!(fixture::git(repo, &["diff", "--name-status"]).is_empty()); + assert_eq!( + fs::metadata(repo.join("tracked.txt")).unwrap().mode() & 0o777, + 0o400 + ); + cleanup(&destination).unwrap(); +} From 6a3d8956149ce39efb8e193a1627af172e5ed666 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 18:32:02 -0500 Subject: [PATCH 5/6] fix(agents): preserve Git executable modes in snapshots Signed-off-by: Tee8z --- voltage/images/buzz-agent/snapshot.py | 2 +- voltage/images/buzz-agent/test-snapshot.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/voltage/images/buzz-agent/snapshot.py b/voltage/images/buzz-agent/snapshot.py index 855ba3b2ba9..81b7124a4ab 100644 --- a/voltage/images/buzz-agent/snapshot.py +++ b/voltage/images/buzz-agent/snapshot.py @@ -327,7 +327,7 @@ def copy_file(repo, descriptor, relative, destination, cp, limit): if total > limit: raise Budget('Snapshot source exceeds budget') sink.write(chunk) - destination.chmod(0o755 if actual.st_mode & 0o111 else 0o644) + destination.chmod(0o755 if actual.st_mode & 0o100 else 0o644) return total finally: os.close(parent) diff --git a/voltage/images/buzz-agent/test-snapshot.py b/voltage/images/buzz-agent/test-snapshot.py index 6417333d832..a6c2ae30df2 100644 --- a/voltage/images/buzz-agent/test-snapshot.py +++ b/voltage/images/buzz-agent/test-snapshot.py @@ -48,10 +48,13 @@ def expect_refusal(workspace,output,category=None,**options): repo=repository(case,format) (repo/'source.rs').write_text('initial\n') (repo/'deleted.txt').write_text('tracked deletion\n') + (repo/'nonowner-exec.txt').write_text('clean non-executable\n') (repo/'.env.example').write_text('PUBLIC_URL=https://example.invalid\n') git(repo,'add','.') git(repo,'commit','-m','initial') git(repo,'branch','local-only') + (repo/'nonowner-exec.txt').chmod(0o654) + assert git(repo,'diff','--name-only','--','nonowner-exec.txt')=='' (repo/'source.rs').write_text('edited\n') (repo/'deleted.txt').unlink() (repo/'new.txt').write_text('untracked\n') @@ -76,7 +79,8 @@ def expect_refusal(workspace,output,category=None,**options): names=package.getnames() assert not any(name.endswith(('/.env','/credentials.json','/.env.example')) for name in names) assert package.getmember('snapshot/repositories/0/files/link').issym() - assert package.getmember('snapshot/repositories/0/files/tool.sh').mode&0o111 + assert package.getmember('snapshot/repositories/0/files/tool.sh').mode&0o100 + assert package.getmember('snapshot/repositories/0/files/nonowner-exec.txt').mode&0o111==0,'non-owner execute bits invented an executable Git file' assert all(member.mtime==0 for member in package) package.extractall(extracted,filter='data') history=case/'history' @@ -86,7 +90,7 @@ def expect_refusal(workspace,output,category=None,**options): assert git(history,'show','HEAD:.env.example').startswith('PUBLIC_URL=') assert 'refs/heads/local-only' in git(history,'show-ref') assert (extracted/'snapshot/repositories/0/files'/long.name).read_text()=='long path\n' - print('PASS: full history, exclusions, dirty files, links, PAX and '+format) + print('PASS: full history, exclusions, dirty files, Git owner-execute semantics, links, PAX and '+format) # Shallow bundle restores with its explicit local boundary, without origin. source=root/'sha1/workspace/example' From 2a16488ba5bbb9bdccb9dc0541b643c449ee8742 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 18:58:10 -0500 Subject: [PATCH 6/6] fix(agents): keep snapshot scratch inside durable job cleanup Signed-off-by: Tee8z --- docs/sandbox-snapshot-producer.md | 13 ++- voltage/images/buzz-agent/snapshot.py | 12 ++- voltage/images/buzz-agent/test-image.sh | 1 + .../buzz-agent/test-snapshot-scratch.py | 90 +++++++++++++++++++ 4 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 voltage/images/buzz-agent/test-snapshot-scratch.py diff --git a/docs/sandbox-snapshot-producer.md b/docs/sandbox-snapshot-producer.md index 0581ba948a8..e7d6d300032 100644 --- a/docs/sandbox-snapshot-producer.md +++ b/docs/sandbox-snapshot-producer.md @@ -6,10 +6,16 @@ The caller must hold a live owner-approved snapshot grant and run the helper und No terminal approval or global gateway delegation alone permits export. ```sh -buzz-agent-snapshot --workspace /workspace --output /tmp/private-job/archive.tar.gz --work-bytes 536870912 +snapshot_job="$(mktemp -d /tmp/buzz-snapshot-job.XXXXXX)" +buzz-agent-snapshot --workspace /workspace --output "$snapshot_job/archive.tar.gz" --work-bytes 536870912 ``` -The output must be new. It is mode 0600 and is removed if creation fails. +The output must be new. Its existing parent must be a private caller-owned job directory. +Scratch is created explicitly inside that parent, independently of `TMPDIR` or Python's cached temporary directory. +If the parent is inside the captured workspace, it must match checkpoint exclusions, such as `.local/state`. +That private subtree is excluded before bounded Git file enumeration. Visible in-source output parents are refused. +The output is mode 0600 and is removed if creation fails. +After forced cancellation, the caller removes the entire job directory through its durable cleanup record. Stdout contains only version, scan status, repository count, and byte count. Failures report only `scan`, `budget`, or `archive`; scanner output and file names are suppressed. The helper never uploads, fetches from a forge, signs relay events, or falls back to quarantine. @@ -59,3 +65,6 @@ The archive is validated and scanned after copying; source work can continue dur Run `python3 voltage/images/buzz-agent/test-snapshot.py ` to exercise packaged code and the actual scanner with no network, a read-only root, UID 10001, dropped capabilities, and a 1 GiB memory cap. The tests cover SHA-1/SHA-256, local branches, nested repositories, dirty/deleted files, executable modes, relative links, long PAX names, shallow boundaries, source/capacity refusal, and current/deleted/merge/tag secret refusal with ignore bypass attempts. The shared policy fixture is checked against checkpoint constants and will also bind the Rust extractor. + +Run `python3 voltage/images/buzz-agent/test-snapshot-scratch.py ` for the real SIGKILL and scratch-path regression. +It covers a cached foreign temporary root, caller cleanup, visible-parent refusal, and private-subtree file-list limits. diff --git a/voltage/images/buzz-agent/snapshot.py b/voltage/images/buzz-agent/snapshot.py index 81b7124a4ab..07eaa99032d 100644 --- a/voltage/images/buzz-agent/snapshot.py +++ b/voltage/images/buzz-agent/snapshot.py @@ -364,6 +364,9 @@ def pack(workspace, output, work_bytes=512 * 1024 * 1024): cp = checkpoint() workspace = Path(workspace).resolve(strict=True) output = Path(output) + output = output.parent.resolve(strict=True) / output.name + if output.parent.is_relative_to(workspace) and not cp.excluded(output.parent.relative_to(workspace)): + raise ValueError('Snapshot output parent must be outside its source or checkpoint-excluded') if output.exists() or output.is_symlink(): raise ValueError('Snapshot output already exists') repos = discover(workspace, cp) @@ -372,7 +375,7 @@ def pack(workspace, output, work_bytes=512 * 1024 * 1024): total = expanded = objects_count = refs_count = 0 count = 0 manifest = {'version': 1, 'snapshot': True, 'scanned': True, 'repositories': []} - with tempfile.TemporaryDirectory(prefix='buzz-snapshot-') as temp: + with tempfile.TemporaryDirectory(prefix='buzz-snapshot-', dir=output.parent) as temp: work = Work(temp, work_bytes) stage = Path(temp) / 'snapshot' review = Path(temp) / 'review' @@ -443,7 +446,12 @@ def pack(workspace, output, work_bytes=512 * 1024 * 1024): if stop < 0: raise Budget('Snapshot bundle header exceeds budget') (object_review / 'references').write_bytes(value[:stop]) - names = cp.git(repo, 'ls-files', '-z', '--cached', '--others', '--exclude-standard') + listing = ['ls-files', '-z', '--cached', '--others', '--exclude-standard'] + source_root = repo.resolve() + if output.parent.is_relative_to(source_root): + private_job = output.parent.relative_to(source_root).as_posix() + listing += ['--', '.', ':(top,exclude,literal)' + private_job] + names = cp.git(repo, *listing) for raw in sorted(set(names.split(b'\0')) - {b''}): name = os.fsdecode(raw) directory_marker = name.endswith('/') diff --git a/voltage/images/buzz-agent/test-image.sh b/voltage/images/buzz-agent/test-image.sh index 9f3233237c5..d76c8744256 100755 --- a/voltage/images/buzz-agent/test-image.sh +++ b/voltage/images/buzz-agent/test-image.sh @@ -53,4 +53,5 @@ docker run --rm --tmpfs /home/agent:uid=10001,gid=10001,mode=0700 \ ' python3 "$(dirname "$0")/test-review.py" "$IMAGE" python3 "$(dirname "$0")/test-snapshot.py" "$IMAGE" +python3 "$(dirname "$0")/test-snapshot-scratch.py" "$IMAGE" printf 'PASS: agent adapter packaging and unauthenticated ACP initialization (%s)\n' "$IMAGE" diff --git a/voltage/images/buzz-agent/test-snapshot-scratch.py b/voltage/images/buzz-agent/test-snapshot-scratch.py new file mode 100644 index 00000000000..6d042176564 --- /dev/null +++ b/voltage/images/buzz-agent/test-snapshot-scratch.py @@ -0,0 +1,90 @@ +"""Prove real producer scratch stays inside durable caller cleanup, including SIGKILL.""" +import subprocess +import sys + +SCRIPT = r''' +import importlib.machinery, importlib.util, json, os, shutil, signal, subprocess, tempfile, time +from pathlib import Path + +def load(): + loader=importlib.machinery.SourceFileLoader('snapshot','/usr/local/bin/buzz-agent-snapshot') + module=importlib.util.module_from_spec(importlib.util.spec_from_loader('snapshot',loader)) + loader.exec_module(module) + return module + +def git(repo,*args): + output=subprocess.run(['git','-C',str(repo),*args],capture_output=True) + assert output.returncode==0,'fixture Git operation failed' + return output.stdout + +with tempfile.TemporaryDirectory() as temporary: + root=Path(temporary);workspace=root/'workspace';workspace.mkdir() + git(workspace,'init','--template=') + git(workspace,'config','user.name','Fixture');git(workspace,'config','user.email','fixture@example.invalid') + (workspace/'safe').write_text('safe\n');git(workspace,'add','safe');git(workspace,'-c','commit.gpgsign=false','commit','-m','fixture') + job=root/'job';job.mkdir(mode=0o700);foreign=root/'foreign';foreign.mkdir() + marker=job/'scratch.json' + child_code=r""" +import importlib.machinery,importlib.util,json,sys,tempfile,time +from pathlib import Path +loader=importlib.machinery.SourceFileLoader('snapshot','/usr/local/bin/buzz-agent-snapshot') +snapshot=importlib.util.module_from_spec(importlib.util.spec_from_loader('snapshot',loader));loader.exec_module(snapshot) +tempfile.tempdir=sys.argv[3] # Deliberately cache a foreign root before pack clears its environment. +original=snapshot.inventory +marker=Path(sys.argv[2]).parent/'scratch.json' +def hold(directory,cp): + result=original(directory,cp) + if Path(directory).name=='history-0': + marker.write_text(json.dumps({'scratch':str(Path(directory).parent.resolve())})) + time.sleep(60) + return result +snapshot.inventory=hold +snapshot.pack(sys.argv[1],sys.argv[2],work_bytes=128*1024*1024) +""" + child=subprocess.Popen(['python3','-c',child_code,str(workspace),str(job/'archive.tar.gz'),str(foreign)],stdout=subprocess.PIPE,stderr=subprocess.PIPE,start_new_session=True) + try: + deadline=time.monotonic()+15 + while not marker.exists() and child.poll() is None and time.monotonic()