use super::*;
use serde_json::{json, Value};
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::Path;
fn fixture_directory() -> tempfile::TempDir {
tempfile::Builder::new()
.tempdir_in(std::fs::canonicalize(std::env::temp_dir()).unwrap())
.unwrap()
}
fn helper(
path: &Path,
request: Value,
body: &[u8],
fault: &str,
) -> strop_core::process::CommandOutput {
let path = path.to_owned();
let mut header = serde_json::to_vec(&request).unwrap();
header.push(b'\n');
let body = body.to_vec();
let source = format!("{fault}\n{}", include_str!("helper.py"));
crate::test_support::in_worker(move |token| {
let mut command = std::process::Command::new("python3");
command.args(["-I", "-S", "-c"]).arg(source).arg(path);
let chunks = [header.as_slice(), body.as_slice()];
let policy = strop_core::process::CapturePolicy {
stdin: strop_core::process::StdinPolicy::HeldInput(&chunks),
deadline: std::time::Duration::from_secs(10),
..Default::default()
};
strop_core::process::capture_with(&mut command, &token, &policy).unwrap()
})
}
fn result(output: &strop_core::process::CommandOutput) -> Value {
serde_json::from_slice::<Value>(&output.stdout)
.unwrap_or_else(|error| panic!("{error}: {}", String::from_utf8_lossy(&output.stderr)))
["result"]
.clone()
}
fn request(operation: &str, text: &str, before: Option<Value>) -> Value {
let digest = ContentDigest::of(&Rope::from_str(text));
let mut value =
json!({"version": 1, "operation": operation, "length": text.len(), "digest": digest});
if let Some(before) = before {
value["before"] = before;
}
value
}
fn baseline(path: &Path, text: &str) -> Value {
let output = helper(path, request("edit", text, None), b"", "");
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stdout)
);
result(&output)["stamp"].clone()
}
fn private_stages(directory: &Path) -> Vec<std::path::PathBuf> {
std::fs::read_dir(directory)
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| {
path.file_name()
.unwrap()
.as_encoded_bytes()
.starts_with(b".strop-save-")
})
.collect()
}
#[test]
fn save_preserves_permissions_time_and_conflicts_on_same_size_changes() {
let directory = fixture_directory();
let path = directory.path().join("file");
std::fs::write(&path, "old\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
let attributes = std::process::Command::new("python3")
.args([
"-I",
"-S",
"-c",
"import os,sys; os.setxattr(sys.argv[1], b'user.strop_test', b'preserved attribute')",
])
.arg(&path)
.output()
.unwrap();
assert!(
attributes.status.success(),
"{}",
String::from_utf8_lossy(&attributes.stderr)
);
let original = std::fs::metadata(&path).unwrap();
let before = baseline(&path, "old\n");
let output = helper(
&path,
request("save", "new\n", Some(before.clone())),
b"new\n",
"",
);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stdout)
);
assert_eq!(std::fs::read(&path).unwrap(), b"new\n");
let metadata = std::fs::metadata(&path).unwrap();
assert_eq!(metadata.permissions().mode() & 0o7777, 0o640);
assert_eq!(
(metadata.mtime(), metadata.mtime_nsec()),
(original.mtime(), original.mtime_nsec())
);
assert!(private_stages(directory.path()).is_empty());
let attributes = std::process::Command::new("python3")
.args([
"-I",
"-S",
"-c",
"import os,sys; sys.stdout.buffer.write(os.getxattr(sys.argv[1], b'user.strop_test'))",
])
.arg(&path)
.output()
.unwrap();
assert!(attributes.status.success());
assert_eq!(attributes.stdout, b"preserved attribute");
let saved = result(&output)["stamp"].clone();
std::fs::write(&path, "else").unwrap();
std::fs::File::open(&path)
.unwrap()
.set_modified(metadata.modified().unwrap())
.unwrap();
let conflict = helper(&path, request("save", "mine", Some(saved)), b"mine", "");
assert!(!conflict.status.success());
assert_eq!(result(&conflict)["kind"], "conflict");
assert_eq!(std::fs::read(&path).unwrap(), b"else");
}
#[test]
fn symlinks_hardlinks_and_protocol_namespace_cannot_gain_write_authority() {
let directory = fixture_directory();
let original = directory.path().join("original");
std::fs::write(&original, "safe").unwrap();
let symlink = directory.path().join("link");
std::os::unix::fs::symlink(&original, &symlink).unwrap();
let linked = helper(&symlink, request("edit", "safe", None), b"", "");
assert!(!linked.status.success());
let hardlink = directory.path().join("hardlink");
std::fs::hard_link(&original, &hardlink).unwrap();
let hard = helper(&hardlink, request("edit", "safe", None), b"", "");
assert_eq!(result(&hard)["kind"], "invalid_path");
let reserved = directory.path().join(".strop-lock-user");
std::fs::write(&reserved, "safe").unwrap();
let control = helper(&reserved, request("edit", "safe", None), b"", "");
assert_eq!(result(&control)["kind"], "invalid_path");
assert_eq!(std::fs::read(&original).unwrap(), b"safe");
}
#[test]
fn crash_after_metadata_restoration_keeps_draft_behind_private_directory() {
let directory = fixture_directory();
let path = directory.path().join("file");
std::fs::write(&path, "old").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let before = baseline(&path, "old");
let fault = "import os, signal\ndef fail_rename(*args, **kwargs):\n os.kill(os.getpid(), signal.SIGKILL)\nos.replace = fail_rename\n";
let crashed = helper(
&path,
request("save", "private draft", Some(before)),
b"private draft",
fault,
);
assert!(!crashed.status.success());
assert_eq!(std::fs::read(&path).unwrap(), b"old");
let stages = private_stages(directory.path());
assert_eq!(stages.len(), 1);
assert_eq!(
std::fs::metadata(&stages[0]).unwrap().permissions().mode() & 0o7777,
0o700
);
assert_eq!(
std::fs::metadata(stages[0].join("contents"))
.unwrap()
.permissions()
.mode()
& 0o7777,
0o644
);
}
#[test]
fn postrename_lost_receipt_requires_explicit_durable_verification() {
let directory = fixture_directory();
let path = directory.path().join("file");
std::fs::write(&path, "before").unwrap();
let before = baseline(&path, "before");
let unchanged = helper(
&path,
request("verify", "after", Some(before.clone())),
b"",
"",
);
assert_eq!(result(&unchanged)["status"], "unchanged");
let fault = "import os, signal\nreplace = os.replace\ndef lose_receipt(*args, **kwargs):\n replace(*args, **kwargs)\n os.kill(os.getpid(), signal.SIGKILL)\nos.replace = lose_receipt\n";
let crashed = helper(
&path,
request("save", "after", Some(before.clone())),
b"after",
fault,
);
assert!(!crashed.status.success());
assert_eq!(std::fs::read(&path).unwrap(), b"after");
let verified = helper(
&path,
request("verify", "after", Some(before.clone())),
b"",
"",
);
assert!(verified.status.success());
assert_eq!(result(&verified)["status"], "written");
std::fs::write(&path, "third party").unwrap();
let conflict = helper(&path, request("verify", "after", Some(before)), b"", "");
assert_eq!(result(&conflict)["kind"], "conflict");
assert_eq!(std::fs::read(&path).unwrap(), b"third party");
}
#[test]
fn observed_termination_during_stage_write_preserves_original_and_cleans_stage() {
let directory = fixture_directory();
let path = directory.path().join("file");
std::fs::write(&path, "before").unwrap();
let before = baseline(&path, "before");
let fault = "import os, signal\nwrite = os.write\ndef stop_during_write(fd, data):\n written = write(fd, data[:1])\n os.kill(os.getpid(), signal.SIGTERM)\n return written\nos.write = stop_during_write\n";
let cancelled = helper(
&path,
request("save", "after", Some(before)),
b"after",
fault,
);
assert_eq!(result(&cancelled)["kind"], "cancelled");
assert_eq!(result(&cancelled)["unconfirmed"], false);
assert_eq!(std::fs::read(&path).unwrap(), b"before");
assert!(private_stages(directory.path()).is_empty());
}
#[test]
fn competing_lock_refuses_save_without_touching_the_original() {
use std::io::{Read, Write};
use std::process::Stdio;
let directory = fixture_directory();
let path = directory.path().join("file");
std::fs::write(&path, "before").unwrap();
let before = baseline(&path, "before");
let lock_path = std::fs::read_dir(directory.path())
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.file_name()
.unwrap()
.as_encoded_bytes()
.starts_with(b".strop-lock-")
})
.unwrap();
crate::test_support::in_worker(move |token| {
let mut command = std::process::Command::new("python3");
command.args(["-I", "-S", "-c", "import os,sys,fcntl; f=open(sys.argv[1],'r+b'); fcntl.flock(f,fcntl.LOCK_EX); os.write(1,b'ready'); sys.stdin.buffer.read(1)"])
.arg(lock_path).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null());
let mut holder = strop_core::process::OwnedProcess::spawn(&mut command, &token).unwrap();
let mut ready = [0u8; 5];
holder
.take_stdout()
.unwrap()
.read_exact(&mut ready)
.unwrap();
assert_eq!(&ready, b"ready");
let refused = helper(&path, request("save", "after", Some(before)), b"after", "");
assert_eq!(result(&refused)["kind"], "busy");
assert_eq!(std::fs::read(&path).unwrap(), b"before");
holder.take_stdin().unwrap().write_all(b"x").unwrap();
assert!(holder.wait().unwrap().success());
});
}