#![allow(clippy::unwrap_used, reason = "tests")]
use std::fs;
use std::path::{Path, PathBuf};
use semver::Version;
use super::apply::windows_safe_name;
use super::fsutil::{join_rel, mode_of, sha256_hex, walk};
use super::macho::tests::signed;
use super::{FORMAT, MAGIC, VersionPair, apply, create, read_info};
use crate::error::Error;
fn pair(from: &str, to: &str) -> VersionPair {
VersionPair::new(Version::parse(from).unwrap(), Version::parse(to).unwrap())
}
fn write(path: &Path, data: &[u8]) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, data).unwrap();
}
fn noise(seed: u64, len: usize) -> Vec<u8> {
let mut x = seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) | 1;
(0..len)
.map(|_| {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
x.to_le_bytes()[3]
})
.collect()
}
fn recompiled(data: &[u8], seed: u64) -> Vec<u8> {
let mut out = data.to_vec();
for i in (0..out.len()).step_by(997) {
out[i] = out[i].wrapping_add(3);
}
out.extend_from_slice(&noise(seed, 5_000));
out
}
fn snapshot(root: &Path) -> Vec<(String, String)> {
let mut out = Vec::new();
for rel in walk(root).unwrap() {
let path = join_rel(root, &rel);
let meta = fs::symlink_metadata(&path).unwrap();
let desc = if meta.file_type().is_symlink() {
format!("link {}", fs::read_link(&path).unwrap().display())
} else if meta.is_dir() {
format!("dir {:o}", mode_of(&meta))
} else {
format!(
"file {:o} {}",
mode_of(&meta),
sha256_hex(&fs::read(&path).unwrap())
)
};
out.push((rel, desc));
}
out
}
fn fixture(dir: &Path) -> (PathBuf, PathBuf) {
let old = dir.join("old/Demo.app");
let new = dir.join("new/Demo.app");
let exe = noise(1, 200_000);
write(&old.join("Contents/MacOS/demo"), &exe);
write(&new.join("Contents/MacOS/demo"), &recompiled(&exe, 2));
write(&old.join("Contents/Info.plist"), b"<plist>1.0.0</plist>");
write(&new.join("Contents/Info.plist"), b"<plist>1.1.0</plist>");
write(&old.join("Contents/Resources/same.txt"), b"unchanged");
write(&new.join("Contents/Resources/same.txt"), b"unchanged");
write(
&old.join("Contents/Resources/old-name.bin"),
&noise(3, 4096),
);
write(&new.join("Contents/Resources/renamed.bin"), &noise(3, 4096));
write(&old.join("Contents/Resources/removed.txt"), b"gone");
write(&new.join("Contents/Resources/added.txt"), b"brand new file");
write(&old.join("Contents/Resources/empty-before"), b"");
write(
&new.join("Contents/Resources/empty-before"),
b"now has content",
);
write(&new.join("Contents/Resources/empty"), b"");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for root in [&old, &new] {
fs::set_permissions(
root.join("Contents/MacOS/demo"),
fs::Permissions::from_mode(0o755),
)
.unwrap();
}
std::os::unix::fs::symlink("Resources/added.txt", new.join("Contents/link")).unwrap();
}
(old, new)
}
fn header_json(delta: &Path) -> serde_json::Value {
let bytes = fs::read(delta).unwrap();
let len = usize::try_from(u64::from_le_bytes(bytes[8..16].try_into().unwrap())).unwrap();
serde_json::from_slice(&bytes[16..16 + len]).unwrap()
}
#[test]
fn round_trips_a_directory_tree() {
let dir = tempfile::tempdir().unwrap();
let (old, new) = fixture(dir.path());
let delta = dir.path().join("demo.delta");
let made = create(&old, &new, &pair("1.0.0", "1.1.0"), &delta).unwrap();
assert_eq!(made.files, 7);
assert_eq!(made.copied, 3, "unchanged + renamed + empty");
assert_eq!(made.patched, 1, "binary");
assert_eq!(made.added, 3, "new file + previously empty file + plist");
let out = dir.path().join("out/Demo.app");
fs::create_dir_all(out.parent().unwrap()).unwrap();
let applied = apply(&delta, &old, &out).unwrap();
assert_eq!(applied, made);
assert_eq!(read_info(&delta).unwrap(), made);
assert_eq!(snapshot(&out), snapshot(&new));
assert!(old.join("Contents/Resources/removed.txt").exists());
assert!(fs::metadata(&delta).unwrap().len() < 40_000);
}
#[test]
fn output_is_deterministic() {
let dir = tempfile::tempdir().unwrap();
let (old, new) = fixture(dir.path());
let (a, b) = (dir.path().join("a.delta"), dir.path().join("b.delta"));
create(&old, &new, &pair("1.0.0", "1.1.0"), &a).unwrap();
create(&old, &new, &pair("1.0.0", "1.1.0"), &b).unwrap();
assert_eq!(fs::read(&a).unwrap(), fs::read(&b).unwrap());
}
#[test]
fn round_trips_a_single_file() {
let dir = tempfile::tempdir().unwrap();
let old = dir.path().join("old-bin");
let new = dir.path().join("new-bin");
let data = noise(7, 100_000);
fs::write(&old, &data).unwrap();
let mut changed = data.clone();
changed[500] ^= 0xff;
fs::write(&new, &changed).unwrap();
let delta = dir.path().join("bin.delta");
let info = create(&old, &new, &pair("1.0.0", "2.0.0"), &delta).unwrap();
assert_eq!((info.files, info.patched), (1, 1));
let out = dir.path().join("rebuilt");
apply(&delta, &old, &out).unwrap();
assert_eq!(fs::read(&out).unwrap(), changed);
}
#[test]
fn stores_unrelated_content_in_full() {
let dir = tempfile::tempdir().unwrap();
let (old, new) = (dir.path().join("old"), dir.path().join("new"));
write(&old.join("data"), &noise(9, 50_000));
write(
&new.join("data"),
"all new, highly repetitive text ".repeat(2000).as_bytes(),
);
let delta = dir.path().join("d.delta");
let info = create(&old, &new, &pair("1.0.0", "1.0.1"), &delta).unwrap();
assert_eq!((info.patched, info.added), (0, 1));
let out = dir.path().join("out");
apply(&delta, &old, &out).unwrap();
assert_eq!(snapshot(&out), snapshot(&new));
}
#[test]
fn patches_signed_mach_o_images_without_their_page_hashes() {
let dir = tempfile::tempdir().unwrap();
let (old, new) = (dir.path().join("old"), dir.path().join("new"));
let code = noise(11, 120_000);
let (old_bin, new_bin) = (signed(&code), signed(&recompiled(&code, 12)));
write(&old.join("Contents/MacOS/demo"), &old_bin);
write(&new.join("Contents/MacOS/demo"), &new_bin);
let delta = dir.path().join("d.delta");
let info = create(&old, &new, &pair("1.0.0", "1.1.0"), &delta).unwrap();
assert_eq!(info.patched, 1);
let header = header_json(&delta);
let file = header["entries"]
.as_array()
.unwrap()
.iter()
.find(|e| e["path"] == "Contents/MacOS/demo")
.unwrap();
assert_eq!(file["source"]["transform"], "macho-code-slots");
let out = dir.path().join("out");
apply(&delta, &old, &out).unwrap();
assert_eq!(fs::read(out.join("Contents/MacOS/demo")).unwrap(), new_bin);
}
#[test]
fn rejects_a_modified_installation_and_cleans_up() {
let dir = tempfile::tempdir().unwrap();
let (old, new) = fixture(dir.path());
let delta = dir.path().join("demo.delta");
create(&old, &new, &pair("1.0.0", "1.1.0"), &delta).unwrap();
write(&old.join("Contents/MacOS/demo"), b"tampered");
let out = dir.path().join("Demo.app");
let err = apply(&delta, &old, &out).unwrap_err();
assert!(
matches!(err, Error::Delta(ref m) if m.contains("Contents/MacOS/demo")),
"{err}"
);
assert!(!out.exists(), "partial output must be removed");
}
#[test]
fn rejects_a_corrupt_patch() {
let dir = tempfile::tempdir().unwrap();
let (old, new) = fixture(dir.path());
let delta = dir.path().join("demo.delta");
create(&old, &new, &pair("1.0.0", "1.1.0"), &delta).unwrap();
let mut bytes = fs::read(&delta).unwrap();
let last = bytes.len() - 10;
bytes[last] ^= 0x55;
fs::write(&delta, &bytes).unwrap();
let out = dir.path().join("Demo.app");
assert!(matches!(apply(&delta, &old, &out), Err(Error::Delta(_))));
assert!(!out.exists());
}
fn handcrafted(dir: &Path, header: &serde_json::Value) -> PathBuf {
let json = serde_json::to_vec(header).unwrap();
let mut bytes = MAGIC.to_vec();
bytes.extend_from_slice(&(json.len() as u64).to_le_bytes());
bytes.extend_from_slice(&json);
let path = dir.join("crafted.delta");
fs::write(&path, bytes).unwrap();
path
}
#[test]
fn rejects_paths_that_escape_the_output() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path().join("base");
fs::create_dir(&base).unwrap();
for path in ["../escape", "/etc/passwd", "a/../../b", "./x", ""] {
let delta = handcrafted(
dir.path(),
&serde_json::json!({
"format": FORMAT, "from": "1.0.0", "to": "1.1.0", "root": "dir",
"entries": [{"path": path, "kind": "dir", "mode": 0o755}]
}),
);
let out = dir.path().join("out");
assert!(
matches!(apply(&delta, &base, &out), Err(Error::Delta(_))),
"{path}"
);
assert!(!out.exists());
}
}
#[test]
fn knows_which_names_windows_stores_as_written() {
for name in [
"a:b",
"note.txt:stream",
"trail.",
"space ",
"...",
"CON",
"nul",
"Aux.txt",
"con .txt",
"com1",
"LPT9.log",
"COM\u{b9}",
] {
assert!(!windows_safe_name(name), "{name}");
}
for name in [
"console.txt",
"conpty.dll",
"COM10.dll",
"LPT",
"nul-device",
".config",
"a.b",
] {
assert!(windows_safe_name(name), "{name}");
}
}
#[cfg(windows)]
#[test]
fn rejects_names_windows_would_read_differently() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path().join("base");
fs::create_dir(&base).unwrap();
for path in ["CON", "aux.txt", "note.txt:stream", "trail.", "space "] {
let delta = handcrafted(
dir.path(),
&serde_json::json!({
"format": FORMAT, "from": "1.0.0", "to": "1.1.0", "root": "dir",
"entries": [{"path": path, "kind": "dir", "mode": 0o755}]
}),
);
let out = dir.path().join("out");
assert!(
matches!(apply(&delta, &base, &out), Err(Error::Delta(ref m)) if m.contains("unsafe path")),
"{path}"
);
assert!(!out.exists());
}
}
#[test]
fn rejects_writing_through_a_symlink() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path().join("base");
fs::create_dir(&base).unwrap();
let delta = handcrafted(
dir.path(),
&serde_json::json!({
"format": FORMAT, "from": "1.0.0", "to": "1.1.0", "root": "dir",
"entries": [
{"path": "link", "kind": "symlink", "target": "../../outside"},
{"path": "link/file", "kind": "dir", "mode": 0o755}
]
}),
);
assert!(matches!(
apply(&delta, &base, &dir.path().join("out")),
Err(Error::Delta(m)) if m.contains("before its parent")
));
}
#[test]
fn rejects_unknown_format_transform_and_bad_magic() {
let dir = tempfile::tempdir().unwrap();
let delta = handcrafted(
dir.path(),
&serde_json::json!({
"format": "files-zstd-v9", "from": "1.0.0", "to": "1.1.0", "root": "dir",
"entries": []
}),
);
assert!(matches!(read_info(&delta), Err(Error::Delta(m)) if m.contains("unsupported")));
let delta = handcrafted(
dir.path(),
&serde_json::json!({
"format": FORMAT, "from": "1.0.0", "to": "1.1.0", "root": "file",
"entries": [{"path": "", "kind": "file", "mode": 0o755, "size": 1, "sha256": "00",
"source": {"op": "patch", "base": "", "base_sha256": "00", "offset": 0,
"len": 0, "transform": "elf-relocations"}}]
}),
);
assert!(matches!(read_info(&delta), Err(Error::Delta(m)) if m.contains("invalid header")));
fs::write(&delta, b"definitely not a delta file").unwrap();
assert!(matches!(read_info(&delta), Err(Error::Delta(m)) if m.contains("magic")));
}