pub use crate::salvage::Attestation;
use crate::salvage::SalvageStatus;
use crate::{EntryKind, Error, Fidelity, FidelityReport};
pub fn check_error_is_classified(e: &Error) -> Result<(), String> {
match e.exit_code() {
1 => Err(format!(
"a decoder raised {e:?}, which maps to exit code 1 — that means \
stuffr failed, not that the input was bad"
)),
_ => check_display_has_no_raw_controls(&e.to_string()),
}
}
pub fn check_display_has_no_raw_controls(rendered: &str) -> Result<(), String> {
match rendered.chars().find(|c| {
matches!(*c,
'\u{0000}'..='\u{001F}'
| '\u{007F}'
| '\u{0080}'..='\u{009F}'
| '\u{202A}'..='\u{202E}'
| '\u{2066}'..='\u{2069}')
}) {
Some(c) => Err(format!(
"a rendered message carries the raw control character U+{:04X}: {rendered:?}",
c as u32
)),
None => Ok(()),
}
}
pub fn check_entry_size(
declared: Option<u64>,
produced: u64,
name: &str,
kind: &EntryKind,
) -> Result<(), String> {
if matches!(kind, EntryKind::Hardlink { .. }) {
return if produced == 0 {
Ok(())
} else {
Err(format!(
"hard link {name:?} yielded {produced} bytes of its own; a link's reader must be empty"
))
};
}
if matches!(kind, EntryKind::Symlink { .. }) {
return Ok(());
}
match declared {
Some(d) if d != produced => Err(format!(
"entry {name:?} declared {d} bytes but produced {produced}"
)),
_ => Ok(()),
}
}
pub fn check_entry_count(
declared: Option<usize>,
enumerated: usize,
report: &FidelityReport,
) -> Result<(), String> {
let Some(d) = declared else { return Ok(()) };
if d == enumerated {
return Ok(());
}
if report
.warnings
.iter()
.any(|w| matches!(w, Fidelity::EntryCountMismatch { .. }))
{
return Ok(());
}
Err(format!(
"the index declares {d} entries but {enumerated} were enumerated, and the \
fidelity report does not carry an EntryCountMismatch warning saying so"
))
}
pub fn check_fidelity_claim(report: &FidelityReport, approximated: bool) -> Result<(), String> {
if report.is_lossless() && approximated {
return Err(format!(
"the report claims nothing was approximated ({:?}, no warnings) \
but something was",
report.rung
));
}
Ok(())
}
pub fn check_entries_carried(
source: &[String],
written: &[String],
report: &FidelityReport,
) -> Result<(), String> {
let arrived: std::collections::HashSet<&str> = written.iter().map(String::as_str).collect();
let skipped: std::collections::HashSet<&str> = report
.warnings
.iter()
.filter_map(|w| match w {
Fidelity::EntrySkipped { entry, .. } | Fidelity::EncryptedEntrySkipped { entry } => {
Some(entry.as_str())
}
_ => None,
})
.collect();
match source
.iter()
.find(|n| !arrived.contains(n.as_str()) && !skipped.contains(n.as_str()))
{
Some(missing) => Err(format!(
"the source held entry {missing:?}, the converted archive does not, and \
the fidelity report names no skip for it"
)),
None => Ok(()),
}
}
pub fn check_extraction_contained(
root: &std::path::Path,
dest: &std::path::Path,
allowed: &[&std::path::Path],
) -> Result<(), String> {
if !dest.starts_with(root) {
return Err(format!(
"the destination {dest:?} is not inside the scratch root {root:?}"
));
}
let dest_exists = match std::fs::symlink_metadata(dest) {
Ok(m) if m.file_type().is_dir() => true,
Ok(m) => {
return Err(format!(
"the destination {dest:?} is no longer a directory ({:?})",
m.file_type()
));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => return Err(format!("cannot inspect the destination {dest:?}: {e}")),
};
let real_dest = if dest_exists {
std::fs::canonicalize(dest)
.map_err(|e| format!("cannot resolve the destination {dest:?}: {e}"))?
} else {
dest.to_path_buf()
};
let mut pending = vec![root.to_path_buf()];
while let Some(dir) = pending.pop() {
let listing =
std::fs::read_dir(&dir).map_err(|e| format!("cannot read directory {dir:?}: {e}"))?;
for item in listing {
let item = item.map_err(|e| format!("cannot read an entry of {dir:?}: {e}"))?;
let path = item.path();
let kind = item
.file_type()
.map_err(|e| format!("cannot inspect {path:?}: {e}"))?;
if allowed.iter().any(|a| path.starts_with(a)) {
continue;
}
if path.starts_with(dest) {
if kind.is_symlink() {
let target = std::fs::read_link(&path)
.map_err(|e| format!("cannot read symlink {path:?}: {e}"))?;
if let Some(real) = resolve_link_physically(&path, &target)?
&& !real.starts_with(&real_dest)
{
return Err(format!(
"symlink {path:?} -> {target:?} resolves physically to {real:?}, \
outside the destination {real_dest:?}"
));
}
let shown = path.to_string_lossy();
match crate::classify_symlink_target(
dest,
&path,
&shown,
&target.to_string_lossy(),
) {
Ok(crate::SymlinkVerdict::Allowed) => {}
Ok(crate::SymlinkVerdict::Skip(reason)) => {
return Err(format!(
"symlink {path:?} -> {target:?} exists, but extraction \
skips that target ({reason}) and must not have created it"
));
}
Err(e) => {
return Err(format!(
"symlink {path:?} -> {target:?} resolves outside the \
destination {dest:?}: {e}"
));
}
}
} else if kind.is_dir() {
pending.push(path);
}
} else if kind.is_dir() && dest.starts_with(&path) {
pending.push(path);
} else {
return Err(format!(
"{path:?} exists outside the destination {dest:?}, and nothing put it \
there but the extraction"
));
}
}
}
Ok(())
}
fn resolve_link_physically(
link: &std::path::Path,
target: &std::path::Path,
) -> Result<Option<std::path::PathBuf>, String> {
use std::path::Component;
match std::fs::canonicalize(link) {
Ok(real) => return Ok(Some(real)),
Err(e) if is_symlink_loop(&e) || is_name_too_long(&e) => return Ok(None),
Err(e) if is_missing_or_through_a_file(&e) => {}
Err(e) => return Err(format!("cannot resolve symlink {link:?}: {e}")),
}
let full = match link.parent() {
Some(dir) => dir.join(target),
None => target.to_path_buf(),
};
for prefix in full.ancestors() {
match std::fs::canonicalize(prefix) {
Ok(mut real) => {
let tail = full
.strip_prefix(prefix)
.map_err(|e| format!("cannot split {full:?} at {prefix:?}: {e}"))?;
for comp in tail.components() {
match comp {
Component::ParentDir => {
real.pop();
}
Component::Normal(part) => real.push(part),
Component::CurDir | Component::RootDir | Component::Prefix(_) => {}
}
}
return Ok(Some(real));
}
Err(e)
if is_missing_or_through_a_file(&e)
|| is_symlink_loop(&e)
|| is_name_too_long(&e) => {}
Err(e) => {
return Err(format!(
"cannot resolve {prefix:?} for symlink {link:?}: {e}"
));
}
}
}
Err(format!("no prefix of {full:?} (symlink {link:?}) resolves"))
}
fn is_missing_or_through_a_file(e: &std::io::Error) -> bool {
matches!(
e.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
)
}
fn is_name_too_long(e: &std::io::Error) -> bool {
e.kind() == std::io::ErrorKind::InvalidFilename
}
fn is_symlink_loop(e: &std::io::Error) -> bool {
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))]
const ELOOP: i32 = 62;
#[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "freebsd")))]
const ELOOP: i32 = 40;
e.raw_os_error() == Some(ELOOP)
}
pub fn check_salvage_claim(
status: &SalvageStatus,
verifier_was_checked: bool,
attestation: Attestation,
) -> Result<(), String> {
match status {
SalvageStatus::Intact => match attestation {
Attestation::ContentChecksum if verifier_was_checked => Ok(()),
Attestation::ContentChecksum => {
Err("reported Intact without checking a checksum".into())
}
Attestation::HeaderChecksumOnly => Err(
"reported Intact — a content checksum agreed — for a format whose only \
checksum covers the header; Complete is that format's tier"
.into(),
),
Attestation::Nothing => Err(
"reported Intact — a checksum agreed — for a format that offers nothing to \
check against"
.into(),
),
},
SalvageStatus::Complete => match attestation {
Attestation::HeaderChecksumOnly if verifier_was_checked => Ok(()),
Attestation::HeaderChecksumOnly => Err(
"reported Complete without checking the header checksum this format offers; \
Unverified is the honest answer when it was not used"
.into(),
),
Attestation::ContentChecksum => Err(
"reported Complete for a format that carries a content checksum; the honest \
answer is Intact if it agreed, Partial if it did not, Unverified if it was \
never compared"
.into(),
),
Attestation::Nothing => Err(
"reported Complete — every declared byte present and the header \
self-verified — for a format with no checksum and no self-verifying header; \
Unattested is that format's tier"
.into(),
),
},
SalvageStatus::Unattested => match attestation {
Attestation::Nothing if !verifier_was_checked => Ok(()),
Attestation::Nothing => Err(
"reported Unattested while claiming a verifier was checked, for a format \
that has none to check"
.into(),
),
Attestation::ContentChecksum | Attestation::HeaderChecksumOnly => Err(
"reported Unattested — nothing attests this is an entry — for a format that \
does offer an attestation"
.into(),
),
},
SalvageStatus::Unverified(_) | SalvageStatus::Partial => Ok(()),
}
}
#[cfg(test)]
mod broken_honesty {
use super::*;
use crate::Error;
use crate::fidelity::Rung;
#[test]
fn an_io_error_from_hostile_bytes_is_refused_because_it_exits_one() {
let e = Error::Io(std::io::Error::other("boom"));
let msg = check_error_is_classified(&e).expect_err("exit 1 must be refused");
assert!(
msg.contains("exit code 1"),
"the failure must name why: {msg}"
);
}
#[test]
fn corrupt_unsupported_and_resource_limit_are_all_permitted() {
for e in [
Error::Corrupt("bad".into()),
Error::Unsupported("nope".into()),
Error::ResourceLimit("too big".into()),
Error::NotSeekable {
format: crate::FormatId::new("zip"),
},
] {
assert!(
check_error_is_classified(&e).is_ok(),
"{e:?} must be permitted"
);
}
}
#[test]
fn a_declared_size_that_disagrees_with_bytes_produced_is_refused() {
let msg = check_entry_size(Some(100), 42, "a.txt", &EntryKind::File)
.expect_err("mismatch must fail");
assert!(msg.contains("a.txt"), "must name the entry: {msg}");
assert!(
msg.contains("100") && msg.contains("42"),
"must name both numbers: {msg}"
);
assert!(check_entry_size(None, 42, "a.txt", &EntryKind::File).is_ok());
assert!(check_entry_size(Some(42), 42, "a.txt", &EntryKind::File).is_ok());
for kind in [EntryKind::Dir, EntryKind::Other] {
assert!(
check_entry_size(Some(100), 42, "a.txt", &kind).is_err(),
"{kind:?} must stay in scope"
);
}
}
#[test]
fn a_symlink_whose_target_was_consumed_eagerly_is_permitted() {
let link = EntryKind::Symlink {
target: "real.txt".into(),
};
assert!(
check_entry_size(Some(8), 0, "link.txt", &link).is_ok(),
"a symlink's target is read by the container, not by the caller"
);
}
#[test]
fn a_declared_count_that_disagrees_with_entries_enumerated_is_refused() {
let silent = FidelityReport::new(Rung::Exact);
let msg = check_entry_count(Some(8), 6, &silent).expect_err("mismatch must fail");
assert!(
msg.contains("8") && msg.contains("6"),
"must name both: {msg}"
);
assert!(check_entry_count(None, 6, &silent).is_ok());
assert!(check_entry_count(Some(6), 6, &silent).is_ok());
}
#[test]
fn a_declared_count_mismatch_the_report_owns_up_to_is_permitted() {
let mut declared = FidelityReport::new(Rung::Exact);
declared.warn(Fidelity::EntryCountMismatch {
format: crate::FormatId::new("zip"),
declared: 8,
enumerated: 6,
reason: "records sharing a name collapse into one".into(),
});
assert!(
check_entry_count(Some(8), 6, &declared).is_ok(),
"a shortfall the report owns up to is a supported outcome, not a violation"
);
let mut unrelated = FidelityReport::new(Rung::Exact);
unrelated.warn(Fidelity::MetadataIncomplete {
entry: "a.txt".into(),
fields: crate::MetaFields::default(),
});
assert!(
check_entry_count(Some(8), 6, &unrelated).is_err(),
"an unrelated warning does not declare a count shortfall"
);
}
#[test]
fn claiming_exact_with_no_warnings_while_approximating_is_refused() {
let clean = FidelityReport::new(Rung::Exact);
let msg = check_fidelity_claim(&clean, true).expect_err("a false claim must fail");
assert!(
msg.contains("approximated"),
"must say what was claimed: {msg}"
);
assert!(check_fidelity_claim(&clean, false).is_ok());
}
#[test]
fn an_entry_a_conversion_drops_silently_is_refused() {
let names = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let clean = FidelityReport::new(Rung::Exact);
let msg = check_entries_carried(&names(&["a", "b"]), &names(&["a"]), &clean)
.expect_err("a dropped entry must fail");
assert!(msg.contains("\"b\""), "must name the entry: {msg}");
let mut other = FidelityReport::new(Rung::Exact);
other.warn(Fidelity::EntrySkipped {
entry: "c".into(),
reason: "special".into(),
});
assert!(check_entries_carried(&names(&["a", "b"]), &names(&["a"]), &other).is_err());
assert!(check_entries_carried(&names(&["a", "b"]), &names(&["b", "a"]), &clean).is_ok());
}
#[test]
fn an_entry_a_conversion_skips_and_names_is_permitted() {
let names = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let mut skipped = FidelityReport::new(Rung::Exact);
skipped.warn(Fidelity::EntrySkipped {
entry: "dir/".into(),
reason: "no directory entries".into(),
});
assert!(
check_entries_carried(&names(&["dir/", "x", "x"]), &names(&["x"]), &skipped).is_ok()
);
let mut encrypted = FidelityReport::new(Rung::Exact);
encrypted.warn(Fidelity::EncryptedEntrySkipped {
entry: "secret".into(),
});
assert!(check_entries_carried(&names(&["secret"]), &[], &encrypted).is_ok());
}
#[test]
fn a_hardlink_declaring_the_shared_size_but_reading_empty_is_permitted() {
let link = EntryKind::Hardlink {
target: "grp/c".into(),
};
assert!(check_entry_size(Some(14), 0, "grp/a", &link).is_ok());
assert!(
check_entry_size(Some(14), 3, "l", &link).is_err(),
"a link whose reader yields bytes breaks the model contract"
);
assert!(check_entry_size(Some(14), 0, "grp/a", &EntryKind::File).is_err());
}
#[test]
fn a_rendered_message_with_a_raw_escape_fails() {
for raw in [
"bad \u{1b}[31m name",
"nul\0",
"del\u{7f}",
"c1\u{85}",
"rtl \u{202e}gpj",
"iso \u{2066}",
] {
let msg =
check_display_has_no_raw_controls(raw).expect_err("a raw control must be refused");
assert!(msg.contains("U+"), "must name the codepoint: {msg}");
}
let msg = check_display_has_no_raw_controls("a\u{202e}b").unwrap_err();
assert!(msg.contains("U+202E"), "{msg}");
}
#[test]
fn an_escaped_rendering_passes() {
assert!(check_display_has_no_raw_controls("plain name.txt").is_ok());
assert!(check_display_has_no_raw_controls("caf\u{e9} \u{4e2d}\u{6587}").is_ok());
assert!(check_display_has_no_raw_controls("esc \\x1b and \\u{202e}").is_ok());
let e = Error::Corrupt("bad \u{1b}[31m \0 \u{202e}name".into());
assert!(check_display_has_no_raw_controls(&e.to_string()).is_ok());
assert!(
check_display_has_no_raw_controls("U+0041 and \u{a0}\u{2028}").is_ok(),
"only the classes fmt_name escapes are refused"
);
}
#[test]
fn a_hardlink_is_carried_as_a_link_a_copy_or_a_named_skip() {
let names = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let clean = FidelityReport::new(Rung::Exact);
assert!(
check_entries_carried(&names(&["a", "link"]), &names(&["a", "link"]), &clean).is_ok()
);
let mut skipped = FidelityReport::new(Rung::Exact);
skipped.warn(Fidelity::EntrySkipped {
entry: "link".into(),
reason: "hard link target `a` was not written".into(),
});
assert!(check_entries_carried(&names(&["a", "link"]), &names(&["a"]), &skipped).is_ok());
let msg = check_entries_carried(&names(&["a", "link"]), &names(&["a"]), &clean)
.expect_err("a silently missing link must fail");
assert!(msg.contains("\"link\""), "{msg}");
}
#[test]
fn an_intact_status_whose_checksum_was_never_checked_is_refused() {
let msg = check_salvage_claim(&SalvageStatus::Intact, false, Attestation::ContentChecksum)
.expect_err("Intact without a checked checksum must be refused");
assert!(msg.contains("Intact"), "must name the status: {msg}");
assert!(
check_salvage_claim(&SalvageStatus::Intact, true, Attestation::ContentChecksum).is_ok()
);
}
#[test]
fn an_intact_status_over_a_format_with_nothing_to_check_is_refused() {
let msg = check_salvage_claim(&SalvageStatus::Intact, true, Attestation::Nothing)
.expect_err("Intact over a format offering nothing must be refused");
assert!(msg.contains("Intact"), "must name the status: {msg}");
}
#[test]
fn an_intact_status_over_a_header_only_checksum_is_refused() {
let msg = check_salvage_claim(
&SalvageStatus::Intact,
true,
Attestation::HeaderChecksumOnly,
)
.expect_err("Intact over a header-only checksum must be refused");
assert!(msg.contains("Intact"), "must name the status: {msg}");
assert!(
msg.contains("Complete"),
"must name the tier that IS honest: {msg}"
);
}
#[test]
fn complete_without_the_attestation_it_claims_is_refused() {
let err = check_salvage_claim(
&SalvageStatus::Complete,
false,
Attestation::HeaderChecksumOnly,
)
.expect_err("Complete whose header checksum went unchecked must be refused");
assert!(
err.contains("Complete"),
"the message must name the tier it refused: {err}"
);
}
#[test]
fn complete_over_a_format_that_attests_nothing_is_refused() {
let err = check_salvage_claim(&SalvageStatus::Complete, false, Attestation::Nothing)
.expect_err("Complete over a format with no attestation at all must be refused");
assert!(err.contains("Complete"), "must name the tier: {err}");
assert!(
err.contains("Unattested"),
"must name the tier that IS honest here: {err}"
);
}
#[test]
fn complete_over_a_content_checksum_is_refused_even_when_checked() {
for checked in [true, false] {
let err = check_salvage_claim(
&SalvageStatus::Complete,
checked,
Attestation::ContentChecksum,
)
.expect_err("Complete over a content checksum must be refused");
assert!(err.contains("Complete"), "must name the tier: {err}");
}
}
#[test]
fn unattested_is_refused_when_the_format_can_self_verify() {
for attestation in [
Attestation::HeaderChecksumOnly,
Attestation::ContentChecksum,
] {
let err = check_salvage_claim(&SalvageStatus::Unattested, false, attestation)
.expect_err("Unattested over an attesting format must be refused");
assert!(
err.contains("Unattested"),
"message must name the tier: {err}"
);
}
}
#[test]
fn unattested_claiming_a_checked_verifier_is_refused() {
let err = check_salvage_claim(&SalvageStatus::Unattested, true, Attestation::Nothing)
.expect_err("a checked verifier in a format with none must be refused");
assert!(
err.contains("Unattested"),
"message must name the tier: {err}"
);
}
#[test]
fn unattested_is_permitted_where_nothing_attests() {
check_salvage_claim(&SalvageStatus::Unattested, false, Attestation::Nothing)
.expect("cpio and ar have no verifier of any kind");
}
#[test]
fn each_class_is_permitted_its_one_honest_tier() {
assert!(
check_salvage_claim(&SalvageStatus::Intact, true, Attestation::ContentChecksum).is_ok(),
"zip/arc/zoo/lha/arj with an agreeing CRC"
);
assert!(
check_salvage_claim(
&SalvageStatus::Complete,
true,
Attestation::HeaderChecksumOnly
)
.is_ok(),
"tar with a validated header checksum"
);
assert!(
check_salvage_claim(&SalvageStatus::Unattested, false, Attestation::Nothing).is_ok(),
"cpio/ar"
);
}
#[test]
fn unverified_and_partial_are_permitted_for_every_class() {
use crate::salvage::UnverifiedCause;
let statuses = [
SalvageStatus::Partial,
SalvageStatus::Unverified(UnverifiedCause::UndecodableMethod),
SalvageStatus::Unverified(UnverifiedCause::NoDeclaredLength),
SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
needed: 2,
ceiling: 1,
}),
];
for status in &statuses {
for attestation in ALL_ATTESTATIONS {
for checked in [true, false] {
assert!(
check_salvage_claim(status, checked, attestation).is_ok(),
"{status:?} / {attestation:?} / checked={checked} must be permitted"
);
}
}
}
}
const ALL_ATTESTATIONS: [Attestation; 3] = [
Attestation::ContentChecksum,
Attestation::HeaderChecksumOnly,
Attestation::Nothing,
];
#[test]
fn the_whole_truth_table_permits_exactly_one_claim_per_class() {
let expected_ok = |status: &SalvageStatus, checked: bool, a: Attestation| match status {
SalvageStatus::Intact => a == Attestation::ContentChecksum && checked,
SalvageStatus::Complete => a == Attestation::HeaderChecksumOnly && checked,
SalvageStatus::Unattested => a == Attestation::Nothing && !checked,
SalvageStatus::Unverified(_) | SalvageStatus::Partial => true,
};
let mut permitted = 0;
for status in [
SalvageStatus::Intact,
SalvageStatus::Complete,
SalvageStatus::Unattested,
] {
for attestation in ALL_ATTESTATIONS {
for checked in [true, false] {
let got = check_salvage_claim(&status, checked, attestation).is_ok();
let want = expected_ok(&status, checked, attestation);
assert_eq!(
got, want,
"{status:?} / {attestation:?} / checked={checked}: oracle said {got}, \
the rule says {want}"
);
permitted += usize::from(got);
}
}
}
assert_eq!(
permitted, 3,
"exactly one cell per claim-bearing tier is honest — 18 cells, 3 permitted"
);
}
fn extraction_tree() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
let root = tempfile::tempdir().unwrap();
let input = root.path().join("input.tar");
std::fs::write(&input, b"bytes").unwrap();
let dest = root.path().join("out");
std::fs::create_dir_all(dest.join("sub")).unwrap();
std::fs::write(dest.join("sub/f"), b"payload").unwrap();
(root, dest, input)
}
#[test]
fn a_file_written_outside_the_destination_is_refused() {
let (root, dest, input) = extraction_tree();
std::fs::write(root.path().join("escape"), b"x").unwrap();
let msg = check_extraction_contained(root.path(), &dest, &[&input])
.expect_err("a file beside the destination must fail");
assert!(msg.contains("escape"), "must name the path: {msg}");
let (root, dest, input) = extraction_tree();
std::fs::create_dir(root.path().join("elsewhere")).unwrap();
assert!(check_extraction_contained(root.path(), &dest, &[&input]).is_err());
}
#[test]
fn a_missing_destination_with_only_allowed_files_is_contained() {
let root = tempfile::tempdir().unwrap();
let input = root.path().join("input.tar");
std::fs::write(&input, b"bytes").unwrap();
let dest = root.path().join("out");
check_extraction_contained(root.path(), &dest, &[&input])
.expect("an extraction refused before creating dest wrote nothing");
}
#[test]
fn a_missing_destination_with_a_stray_file_is_refused() {
let root = tempfile::tempdir().unwrap();
let input = root.path().join("input.tar");
std::fs::write(&input, b"bytes").unwrap();
std::fs::write(root.path().join("stray"), b"x").unwrap();
let dest = root.path().join("out");
let msg = check_extraction_contained(root.path(), &dest, &[&input])
.expect_err("a stray file must fail even with dest absent");
assert!(msg.contains("stray"), "must name the path: {msg}");
}
#[test]
fn a_destination_that_is_a_file_is_still_refused() {
let root = tempfile::tempdir().unwrap();
let dest = root.path().join("out");
std::fs::write(&dest, b"x").unwrap();
let msg = check_extraction_contained(root.path(), &dest, &[])
.expect_err("a file destination must fail");
assert!(msg.contains("no longer a directory"), "{msg}");
}
#[cfg(unix)]
#[test]
fn a_symlink_with_an_unresolvably_long_component_is_judged_lexically_only() {
let (root, dest, input) = extraction_tree();
let long = "a".repeat(300);
std::os::unix::fs::symlink(&long, dest.join("sub/ok")).unwrap();
check_extraction_contained(root.path(), &dest, &[&input])
.expect("a contained but unresolvable link is not a finding");
let (root, dest, input) = extraction_tree();
std::os::unix::fs::symlink(format!("../../{long}"), dest.join("sub/bad")).unwrap();
let msg = check_extraction_contained(root.path(), &dest, &[&input])
.expect_err("a lexically escaping link must still fail");
assert!(msg.contains("bad"), "{msg}");
}
#[cfg(unix)]
#[test]
fn a_symlink_under_the_destination_resolving_outside_it_is_refused() {
let (root, dest, input) = extraction_tree();
std::os::unix::fs::symlink("../../etc", dest.join("s")).unwrap();
let msg = check_extraction_contained(root.path(), &dest, &[&input])
.expect_err("dest/s -> ../../etc must fail");
assert!(msg.contains("etc"), "must name the target: {msg}");
let (root, dest, input) = extraction_tree();
std::os::unix::fs::symlink("../..", dest.join("sub/up")).unwrap();
assert!(check_extraction_contained(root.path(), &dest, &[&input]).is_err());
let (root, dest, input) = extraction_tree();
std::os::unix::fs::symlink("/etc", dest.join("abs")).unwrap();
assert!(check_extraction_contained(root.path(), &dest, &[&input]).is_err());
let root2 = tempfile::tempdir().unwrap();
let elsewhere = tempfile::tempdir().unwrap();
let dest2 = root2.path().join("out");
std::os::unix::fs::symlink(elsewhere.path(), &dest2).unwrap();
assert!(check_extraction_contained(root2.path(), &dest2, &[]).is_err());
}
#[cfg(unix)]
#[test]
fn a_symlink_resolving_inside_the_destination_is_permitted() {
let (root, dest, input) = extraction_tree();
std::os::unix::fs::symlink("sub/f", dest.join("s")).unwrap();
std::os::unix::fs::symlink("../sub/f", dest.join("sub/up")).unwrap();
std::os::unix::fs::symlink("..", dest.join("sub/top")).unwrap();
std::os::unix::fs::symlink("dangling", dest.join("d")).unwrap();
check_extraction_contained(root.path(), &dest, &[&input])
.expect("links resolving inside the destination are contained");
}
#[cfg(unix)]
#[test]
fn a_symlink_chain_resolving_outside_the_destination_physically_is_refused() {
let (root, dest, input) = extraction_tree();
std::fs::create_dir(dest.join("x")).unwrap();
std::os::unix::fs::symlink("..", dest.join("x/s2")).unwrap();
std::os::unix::fs::symlink("x/s2/..", dest.join("s1")).unwrap();
let msg = check_extraction_contained(root.path(), &dest, &[&input])
.expect_err("dest/s1 -> x/s2/.. resolves to dest/.. and must fail");
assert!(msg.contains("resolves physically"), "{msg}");
assert!(msg.contains("s1"), "must name the link: {msg}");
let (root, dest, input) = extraction_tree();
std::fs::create_dir(dest.join("x")).unwrap();
std::os::unix::fs::symlink("..", dest.join("x/s2")).unwrap();
std::os::unix::fs::symlink("x/s2/sub/f", dest.join("s1")).unwrap();
std::os::unix::fs::symlink("x/s2", dest.join("s3")).unwrap();
std::os::unix::fs::symlink("loop", dest.join("loop")).unwrap();
check_extraction_contained(root.path(), &dest, &[&input])
.expect("a chain that stays inside the destination is contained");
}
#[cfg(unix)]
#[test]
fn a_dangling_link_resolving_outside_the_destination_is_refused() {
let (root, dest, input) = extraction_tree();
std::os::unix::fs::symlink("../../nonexistent", dest.join("sub/s")).unwrap();
let msg = check_extraction_contained(root.path(), &dest, &[&input])
.expect_err("dest/sub/s -> ../../nonexistent escapes and must fail");
assert!(msg.contains("resolves physically"), "{msg}");
let (root, dest, input) = extraction_tree();
std::os::unix::fs::symlink("../../no/such/file", dest.join("sub/s")).unwrap();
let msg = check_extraction_contained(root.path(), &dest, &[&input])
.expect_err("a multi-component missing tail outside dest must fail");
assert!(msg.contains("resolves physically"), "{msg}");
}
#[cfg(unix)]
#[test]
fn a_dangling_contained_link_and_a_loop_are_permitted() {
let (root, dest, input) = extraction_tree();
std::os::unix::fs::symlink("../missing/deeper", dest.join("sub/d")).unwrap();
std::os::unix::fs::symlink("nothing-here", dest.join("d2")).unwrap();
std::os::unix::fs::symlink("l2", dest.join("l1")).unwrap();
std::os::unix::fs::symlink("l1", dest.join("l2")).unwrap();
std::os::unix::fs::symlink("self", dest.join("self")).unwrap();
check_extraction_contained(root.path(), &dest, &[&input])
.expect("dangling contained links and loops are contained");
}
#[cfg(unix)]
#[test]
fn a_link_through_a_regular_file_is_resolved_not_refused() {
let (root, dest, input) = extraction_tree();
std::os::unix::fs::symlink("sub/f/x", dest.join("l")).unwrap();
std::os::unix::fs::symlink("f/x/y", dest.join("sub/m")).unwrap();
check_extraction_contained(root.path(), &dest, &[&input])
.expect("a link through a file inside dest is contained");
let (root, dest, input) = extraction_tree();
std::os::unix::fs::symlink("../../input.tar/x", dest.join("sub/s")).unwrap();
let msg = check_extraction_contained(root.path(), &dest, &[&input])
.expect_err("sub/s -> ../../input.tar/x escapes and must fail");
assert!(msg.contains("resolves physically"), "{msg}");
}
#[cfg(unix)]
#[test]
fn an_existing_link_extraction_would_have_skipped_is_refused() {
let (root, dest, input) = extraction_tree();
std::os::unix::fs::symlink("sub/../sub/f", dest.join("s")).unwrap();
let msg = check_extraction_contained(root.path(), &dest, &[&input])
.expect_err("a link of a skipped shape must not exist");
assert!(msg.contains("must not have created it"), "{msg}");
assert!(msg.contains("after a name"), "{msg}");
}
#[test]
fn the_targets_own_input_and_a_clean_tree_are_permitted() {
let (root, dest, input) = extraction_tree();
check_extraction_contained(root.path(), &dest, &[&input])
.expect("a clean extraction plus its allowed input is contained");
let msg = check_extraction_contained(root.path(), &dest, &[])
.expect_err("the input is outside `dest` once it is not allowed");
assert!(msg.contains("input.tar"), "{msg}");
let root = tempfile::tempdir().unwrap();
let dest = root.path().join("a/b/out");
std::fs::create_dir_all(&dest).unwrap();
check_extraction_contained(root.path(), &dest, &[]).expect("empty dest is contained");
}
}