use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::{Command, Output};
use assert_cmd::prelude::*;
use assert_fs::prelude::*;
use assert_fs::TempDir;
fn snapdir(cache: &Path) -> Command {
let mut cmd = Command::cargo_bin("snapdir").expect("snapdir binary built");
cmd.env("SNAPDIR_CACHE_DIR", cache);
cmd.env_remove("NO_COLOR");
cmd.env("TERM", "xterm-256color");
cmd
}
fn build_tree(dir: &TempDir) {
dir.child("a.txt").write_str("hello").unwrap();
std::fs::set_permissions(dir.child("a.txt").path(), PermissionsExt::from_mode(0o644)).unwrap();
dir.child("sub/b.txt").write_str("world!!").unwrap();
std::fs::set_permissions(
dir.child("sub/b.txt").path(),
PermissionsExt::from_mode(0o600),
)
.unwrap();
std::fs::set_permissions(dir.child("sub").path(), PermissionsExt::from_mode(0o755)).unwrap();
std::fs::set_permissions(dir.path(), PermissionsExt::from_mode(0o755)).unwrap();
}
fn run_ok(cache: &Path, args: &[&str]) -> Output {
let out = snapdir(cache).args(args).output().expect("run snapdir");
assert!(
out.status.success(),
"snapdir {args:?} failed ({:?})\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr),
);
out
}
fn stdout_str(out: &Output) -> String {
String::from_utf8(out.stdout.clone())
.unwrap()
.trim_end()
.to_owned()
}
fn has_ansi(bytes: &[u8]) -> bool {
bytes.contains(&0x1b)
}
fn has_cr(bytes: &[u8]) -> bool {
bytes.contains(&b'\r')
}
fn assert_clean(out: &Output, label: &str) {
assert!(
!has_ansi(&out.stdout),
"{label}: stdout must carry no ANSI (0x1b) byte"
);
assert!(
!has_cr(&out.stdout),
"{label}: stdout must carry no CR redraw"
);
assert!(
!has_ansi(&out.stderr),
"{label}: stderr must carry no ANSI (0x1b) byte (piped => progress off)"
);
assert!(
!has_cr(&out.stderr),
"{label}: stderr must carry no CR redraw (piped => progress off)"
);
}
fn assert_is_id(id: &str, label: &str) {
assert_eq!(id.len(), 64, "{label}: stdout must be the bare id: {id:?}");
assert!(
id.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"{label}: stdout must be lowercase hex: {id:?}"
);
}
#[test]
fn progress_e2e_push_scriptable() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let store_a = TempDir::new().unwrap();
build_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let a_url = format!("file://{}", store_a.path().display());
let push = run_ok(cache.path(), &["push", "--store", &a_url, &src_str]);
let id = stdout_str(&push);
assert_is_id(&id, "push");
assert_eq!(
push.stdout,
format!("{id}\n").into_bytes(),
"push stdout must be exactly the id + one trailing newline"
);
assert_clean(&push, "push");
}
#[test]
fn progress_e2e_pull_scriptable() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let store_a = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
build_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let dest_str = dest.path().to_string_lossy().into_owned();
let a_url = format!("file://{}", store_a.path().display());
let id = stdout_str(&run_ok(
cache.path(),
&["push", "--store", &a_url, &src_str],
));
assert_is_id(&id, "push (for pull)");
let pull = run_ok(
cache.path(),
&["pull", "--store", &a_url, "--id", &id, &dest_str],
);
assert_clean(&pull, "pull");
dest.child("a.txt").assert("hello");
dest.child("sub/b.txt").assert("world!!");
assert_eq!(
stdout_str(&run_ok(cache.path(), &["id", &dest_str])),
id,
"pulled tree must re-manifest to the source id"
);
}
#[test]
fn progress_e2e_sync_scriptable() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let store_a = TempDir::new().unwrap();
let store_b = TempDir::new().unwrap();
build_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let a_url = format!("file://{}", store_a.path().display());
let b_url = format!("file://{}", store_b.path().display());
let id = stdout_str(&run_ok(
cache.path(),
&["push", "--store", &a_url, &src_str],
));
assert_is_id(&id, "push (for sync)");
let sync = run_ok(
cache.path(),
&["sync", "--id", &id, "--from", &a_url, "--to", &b_url],
);
assert_eq!(stdout_str(&sync), id, "sync stdout must be the bare id");
assert_clean(&sync, "sync");
let dest = TempDir::new().unwrap();
let dest_str = dest.path().to_string_lossy().into_owned();
let cache_b = TempDir::new().unwrap();
run_ok(
cache_b.path(),
&["pull", "--store", &b_url, "--id", &id, &dest_str],
);
dest.child("a.txt").assert("hello");
dest.child("sub/b.txt").assert("world!!");
assert_eq!(
stdout_str(&run_ok(cache_b.path(), &["id", &dest_str])),
id,
"B must serve the synced snapshot (re-manifests to the source id)"
);
}
#[test]
fn progress_e2e_flags_matrix() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
build_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let flag_sets: &[&[&str]] = &[&["--no-progress"], &["--quiet"], &["--color", "never"]];
for flags in flag_sets {
let store = TempDir::new().unwrap();
let url = format!("file://{}", store.path().display());
let mut args: Vec<&str> = flags.to_vec();
args.extend_from_slice(&["push", "--store", &url, &src_str]);
let out = run_ok(cache.path(), &args);
let id = stdout_str(&out);
assert_is_id(&id, &format!("push {flags:?}"));
assert_clean(&out, &format!("push {flags:?}"));
let store_b = TempDir::new().unwrap();
let b_url = format!("file://{}", store_b.path().display());
let mut sync_args: Vec<&str> = flags.to_vec();
sync_args.extend_from_slice(&["sync", "--id", &id, "--from", &url, "--to", &b_url]);
let sync = run_ok(cache.path(), &sync_args);
assert_eq!(
stdout_str(&sync),
id,
"sync {flags:?} stdout must be the bare id"
);
assert_clean(&sync, &format!("sync {flags:?}"));
}
let store = TempDir::new().unwrap();
let url = format!("file://{}", store.path().display());
let out = run_ok(
cache.path(),
&["--verbose", "--quiet", "push", "--store", &url, &src_str],
);
let id = stdout_str(&out);
assert_is_id(&id, "--verbose --quiet push");
assert_clean(&out, "--verbose --quiet push");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("transfers:"),
"--quiet must suppress the --verbose banner; stderr was: {stderr:?}"
);
}
#[test]
fn progress_e2e_id_identical_with_without_flags() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
build_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let plain_store = TempDir::new().unwrap();
let plain_url = format!("file://{}", plain_store.path().display());
let plain = stdout_str(&run_ok(
cache.path(),
&["push", "--store", &plain_url, &src_str],
));
assert_is_id(&plain, "plain push");
let np_store = TempDir::new().unwrap();
let np_url = format!("file://{}", np_store.path().display());
let no_progress = stdout_str(&run_ok(
cache.path(),
&["--no-progress", "push", "--store", &np_url, &src_str],
));
let q_store = TempDir::new().unwrap();
let q_url = format!("file://{}", q_store.path().display());
let quiet = stdout_str(&run_ok(
cache.path(),
&["--quiet", "push", "--store", &q_url, &src_str],
));
assert_eq!(plain, no_progress, "id must not depend on --no-progress");
assert_eq!(plain, quiet, "id must not depend on --quiet");
}
#[test]
fn progress_e2e_pty_renders_when_tty() {
if std::env::var_os("SNAPDIR_PTY_TEST").is_none() {
eprintln!(
"progress_e2e_pty_renders_when_tty: SKIP (set SNAPDIR_PTY_TEST=1 to run the live \
pty smoke). Deterministic render proof: the cli-progress-renderer golden tests \
in src/progress.rs."
);
return;
}
if let Err(reason) = run_pty_smoke() {
eprintln!("progress_e2e_pty_renders_when_tty: SKIP ({reason})");
}
}
#[cfg(unix)]
#[allow(clippy::borrow_as_ptr, clippy::too_many_lines)]
fn run_pty_smoke() -> Result<(), String> {
use std::io::Read;
use std::os::unix::io::FromRawFd;
use std::os::unix::process::CommandExt;
use std::time::{Duration, Instant};
let cache = TempDir::new().map_err(|e| format!("tempdir: {e}"))?;
let src = TempDir::new().map_err(|e| format!("tempdir: {e}"))?;
for i in 0..64 {
src.child(format!("f{i}.dat"))
.write_str(&"payload-".repeat(64))
.map_err(|e| format!("write fixture: {e}"))?;
}
let src_str = src.path().to_string_lossy().into_owned();
let store_a = TempDir::new().map_err(|e| format!("tempdir: {e}"))?;
let store_b = TempDir::new().map_err(|e| format!("tempdir: {e}"))?;
let a_url = format!("file://{}", store_a.path().display());
let b_url = format!("file://{}", store_b.path().display());
let id = stdout_str(&run_ok(
cache.path(),
&["push", "--store", &a_url, &src_str],
));
let mut master: libc::c_int = -1;
let mut slave: libc::c_int = -1;
let rc = unsafe {
libc::openpty(
&mut master,
&mut slave,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
if rc != 0 {
return Err(format!(
"openpty failed: {}",
std::io::Error::last_os_error()
));
}
let mut child = {
let mut cmd = snapdir(cache.path());
cmd.args(["sync", "--id", &id, "--from", &a_url, "--to", &b_url]);
cmd.env("TERM", "xterm");
cmd.stdout(std::process::Stdio::piped());
cmd.stdin(std::process::Stdio::null());
let slave_for_child = slave;
unsafe {
cmd.pre_exec(move || {
if libc::dup2(slave_for_child, 2) == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
cmd.spawn().map_err(|e| {
unsafe {
libc::close(master);
libc::close(slave);
}
format!("spawn under pty: {e}")
})?
};
unsafe {
libc::close(slave);
}
let mut master_file = unsafe { std::fs::File::from_raw_fd(master) };
set_nonblocking(master).map_err(|e| format!("set master nonblocking: {e}"))?;
let mut pty_bytes: Vec<u8> = Vec::new();
let deadline = Instant::now() + Duration::from_secs(20);
let mut buf = [0u8; 4096];
loop {
match master_file.read(&mut buf) {
Ok(0) => break, Ok(n) => pty_bytes.extend_from_slice(&buf[..n]),
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
if Instant::now() >= deadline {
let _ = child.kill();
return Err("timeout reading pty master".into());
}
std::thread::sleep(Duration::from_millis(10));
}
Err(e) if e.raw_os_error() == Some(libc::EIO) => break,
Err(e) => return Err(format!("read pty master: {e}")),
}
if Instant::now() >= deadline {
let _ = child.kill();
return Err("timeout draining pty master".into());
}
}
let out = child
.wait_with_output()
.map_err(|e| format!("wait for child: {e}"))?;
if !out.status.success() {
return Err(format!(
"child sync under pty failed ({:?}); stderr(pty): {}",
out.status.code(),
String::from_utf8_lossy(&pty_bytes)
));
}
assert!(
has_ansi(&pty_bytes) || has_cr(&pty_bytes),
"pty stderr must carry the live progress line (ANSI/CR); captured {} bytes: {:?}",
pty_bytes.len(),
String::from_utf8_lossy(&pty_bytes)
);
let stdout_id = String::from_utf8_lossy(&out.stdout).trim_end().to_owned();
assert_eq!(
stdout_id, id,
"stdout must stay exactly the snapshot id even while the pty renders"
);
assert!(
!has_ansi(&out.stdout) && !has_cr(&out.stdout),
"stdout (normal pipe) must remain ANSI/CR-free under a TTY stderr"
);
Ok(())
}
#[cfg(not(unix))]
fn run_pty_smoke() -> Result<(), String> {
Err("pty smoke is unix-only".into())
}
#[cfg(unix)]
fn set_nonblocking(fd: libc::c_int) -> std::io::Result<()> {
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
if flags == -1 {
return Err(std::io::Error::last_os_error());
}
let rc = unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) };
if rc == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}