use std::io::{Read, Write};
use std::path::Path;
use crate::archive::EntryMeta;
use crate::error::{Error, Result};
use crate::format::FormatId;
use crate::source::SeekRead;
pub const MAX_SALVAGE_ENTRY: u64 = 4 * 1024 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verifier {
Crc32(u32),
Crc16(u16),
}
#[derive(Debug)]
#[non_exhaustive]
pub struct Candidate {
pub offset: u64,
pub payload_start: u64,
pub meta: EntryMeta,
pub declared_len: Option<u64>,
pub verifier: Option<Verifier>,
pub available_len: Option<u64>,
pub marked_deleted: bool,
}
impl Candidate {
pub fn new(offset: u64, payload_start: u64, meta: EntryMeta) -> Self {
Self {
offset,
payload_start,
meta,
declared_len: None,
verifier: None,
available_len: None,
marked_deleted: false,
}
}
#[must_use]
pub fn with_declared_len(mut self, declared_len: Option<u64>) -> Self {
self.declared_len = declared_len;
self
}
#[must_use]
pub fn with_verifier(mut self, verifier: Option<Verifier>) -> Self {
self.verifier = verifier;
self
}
#[must_use]
pub fn with_available_len(mut self, available_len: Option<u64>) -> Self {
self.available_len = available_len;
self
}
#[must_use]
pub fn with_marked_deleted(mut self, marked_deleted: bool) -> Self {
self.marked_deleted = marked_deleted;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnverifiedCause {
UndecodableMethod,
NoDeclaredLength,
OverEntryCeiling {
needed: u64,
ceiling: u64,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SalvageStatus {
Intact,
Complete,
Unattested,
Unverified(UnverifiedCause),
Partial,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Attestation {
ContentChecksum,
HeaderChecksumOnly,
Nothing,
}
#[derive(Debug)]
#[non_exhaustive]
pub struct SalvagedEntry {
pub scan_position: usize,
pub offset: u64,
pub payload_start: u64,
pub meta: EntryMeta,
pub status: SalvageStatus,
pub shadows: Option<usize>,
pub collides_with: Option<usize>,
pub marked_deleted: bool,
}
impl SalvagedEntry {
pub fn new(
scan_position: usize,
offset: u64,
payload_start: u64,
meta: EntryMeta,
status: SalvageStatus,
) -> Self {
Self {
scan_position,
offset,
payload_start,
meta,
status,
shadows: None,
collides_with: None,
marked_deleted: false,
}
}
#[must_use]
pub fn with_shadows(mut self, shadows: Option<usize>) -> Self {
self.shadows = shadows;
self
}
#[must_use]
pub fn with_collides_with(mut self, collides_with: Option<usize>) -> Self {
self.collides_with = collides_with;
self
}
#[must_use]
pub fn with_marked_deleted(mut self, marked_deleted: bool) -> Self {
self.marked_deleted = marked_deleted;
self
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct SalvageOutcome {
pub entries: Vec<SalvagedEntry>,
pub sightings: Vec<Sighting>,
pub walk_stop: Option<WalkStop>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Sighting {
pub format: FormatId,
pub offset: u64,
pub shape: &'static str,
}
impl Sighting {
pub fn new(format: FormatId, offset: u64, shape: &'static str) -> Self {
Self {
format,
offset,
shape,
}
}
}
const SIGHTING_OFFSETS_SHOWN: usize = 8;
pub fn describe_sightings(sightings: &[Sighting]) -> Option<String> {
if sightings.is_empty() {
return None;
}
let mut groups: Vec<((FormatId, &'static str), Vec<u64>)> = Vec::new();
for s in sightings {
let key = (s.format, s.shape);
match groups.iter_mut().find(|(k, _)| *k == key) {
Some((_, offsets)) => offsets.push(s.offset),
None => groups.push((key, vec![s.offset])),
}
}
let parts: Vec<String> = groups
.iter()
.map(|((format, shape), offsets)| {
let mut shown: Vec<String> = offsets
.iter()
.take(SIGHTING_OFFSETS_SHOWN)
.map(u64::to_string)
.collect();
if offsets.len() > SIGHTING_OFFSETS_SHOWN {
shown.push(format!(
"and {} more",
offsets.len() - SIGHTING_OFFSETS_SHOWN
));
}
format!(
"{} {format} {shape} at offset(s) {}",
offsets.len(),
shown.join(", ")
)
})
.collect();
Some(format!(
"this build's salvage scanner saw {} — each with the structure of a header, but in a \
shape it has no gate for, so none is listed or recovered; `stuffr list` and \
`stuffr unpack` may read these headers normally, or name the variant they are in, \
and it is the SCAN that stops there",
parts.join("; and ")
))
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct WalkStop {
pub format: FormatId,
pub offset: u64,
pub remaining: u64,
pub kind: WalkStopKind,
pub cause: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WalkStopKind {
CutShort,
Unreadable,
UnsupportedShape,
}
impl WalkStop {
pub fn new(
format: FormatId,
offset: u64,
remaining: u64,
kind: WalkStopKind,
cause: impl Into<String>,
) -> Self {
Self {
format,
offset,
remaining,
kind,
cause: cause.into(),
}
}
}
pub fn describe_walk_stop(stop: &WalkStop) -> String {
let WalkStop {
format,
offset,
remaining,
kind,
cause,
} = stop;
let bytes = if *remaining == 1 { "byte" } else { "bytes" };
match kind {
WalkStopKind::Unreadable => format!(
"this build's {format} salvage scanner walks the archive in order from its start, \
as `stuffr list` does, and stopped at offset {offset} with {remaining} {bytes} still \
to go: {cause}. The {format} format marks its records with nothing strong enough \
to search for, so the scanner cannot find its way past a record it cannot read; \
any entries in those {remaining} {bytes} are unreachable by construction — not \
absent, and not counted in this report"
),
WalkStopKind::UnsupportedShape => format!(
"this build's {format} salvage scanner walks the archive in order from its start, \
as `stuffr list` does, and stopped at offset {offset} with {remaining} {bytes} still \
to go: {cause}. That is a record shape this build does not read, not evidence of \
damage; any entries in those {remaining} {bytes} are unreachable to this scanner \
and not counted in this report"
),
WalkStopKind::CutShort => format!(
"this build's {format} salvage scanner walks the archive in order from its start, \
and the file ends {remaining} {bytes} into the record at offset {offset} ({cause}): \
too few for any further entry, so the archive is cut short there and nothing \
after that point is missing from this report"
),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PartialPolicy {
Keep,
Skip,
Ask,
}
pub struct SalvagePolicy {
pub partial: PartialPolicy,
pub max_entry: u64,
pub strict: bool,
}
impl Default for SalvagePolicy {
fn default() -> Self {
Self {
partial: PartialPolicy::Keep,
max_entry: MAX_SALVAGE_ENTRY,
strict: false,
}
}
}
pub trait SalvageScan {
fn next_candidate(&mut self, src: &mut dyn SeekRead, from: u64) -> Result<Option<Candidate>>;
fn max_whole_entry(&self) -> u64 {
u64::MAX
}
fn verify(&self, _src: &mut dyn SeekRead, _candidate: &Candidate) -> Result<SalvageStatus> {
Ok(SalvageStatus::Unattested)
}
fn write_payload(
&self,
_archive_path: &Path,
_entry: &SalvagedEntry,
_compressed_len: u64,
_out: &mut dyn Write,
) -> Result<bool> {
Err(Error::Unsupported(
"this salvage scanner has no payload writer".to_string(),
))
}
}
pub fn salvage_all(
scan: &mut dyn SalvageScan,
src: &mut dyn SeekRead,
policy: &SalvagePolicy,
) -> Result<SalvageOutcome> {
let candidates = collect_candidates(scan, src)?;
annotate_candidates(&*scan, src, candidates, policy)
}
pub fn collect_candidates(
scan: &mut dyn SalvageScan,
src: &mut dyn SeekRead,
) -> Result<Vec<Candidate>> {
let mut candidates = Vec::new();
let mut from = 0u64;
while let Some(candidate) = scan.next_candidate(src, from)? {
let advance = if candidate.available_len.is_some() {
1
} else {
candidate.declared_len.unwrap_or(1).max(1)
};
from = candidate.offset.saturating_add(advance);
candidates.push(candidate);
}
Ok(candidates)
}
pub fn annotate_candidates(
scan: &dyn SalvageScan,
src: &mut dyn SeekRead,
candidates: Vec<Candidate>,
policy: &SalvagePolicy,
) -> Result<SalvageOutcome> {
let ceiling = policy.max_entry.min(scan.max_whole_entry());
let mut entries = Vec::with_capacity(candidates.len());
let mut seen: Vec<(String, Option<u64>, Verifier, usize)> = Vec::new();
let mut names_seen: Vec<(String, usize)> = Vec::new();
for (scan_position, candidate) in candidates.into_iter().enumerate() {
let bounded_len = candidate.available_len.or(candidate.declared_len);
let status = match bounded_len {
Some(needed) if needed > ceiling => {
SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling { needed, ceiling })
}
_ => scan.verify(src, &candidate)?,
};
let declared_len = candidate.declared_len;
let verifier = candidate.verifier;
let is_degenerate = declared_len == Some(0);
let shadows = if is_degenerate {
None
} else {
verifier.and_then(|v| {
seen.iter()
.find(|&&(ref seen_name, seen_len, seen_verifier, _)| {
seen_verifier == v
&& seen_len == declared_len
&& *seen_name == candidate.meta.name
})
.map(|&(_, _, _, earlier_position)| earlier_position)
})
};
if !is_degenerate
&& shadows.is_none()
&& let Some(v) = verifier
{
seen.push((candidate.meta.name.clone(), declared_len, v, scan_position));
}
let earliest_with_name = names_seen
.iter()
.find(|(name, _)| *name == candidate.meta.name)
.map(|&(_, position)| position);
let collides_with = if shadows.is_some() {
None
} else {
earliest_with_name
};
if earliest_with_name.is_none() {
names_seen.push((candidate.meta.name.clone(), scan_position));
}
let marked_deleted = candidate.marked_deleted;
entries.push(
SalvagedEntry::new(
scan_position,
candidate.offset,
candidate.payload_start,
candidate.meta,
status,
)
.with_shadows(shadows)
.with_collides_with(collides_with)
.with_marked_deleted(marked_deleted),
);
}
Ok(SalvageOutcome {
entries,
sightings: Vec::new(),
walk_stop: None,
})
}
pub fn stream_bounded_copy(
mut reader: impl Read,
expected_len: u64,
out: &mut dyn Write,
) -> Result<bool> {
let mut buf = [0u8; 64 * 1024];
let mut produced = 0u64;
loop {
if produced >= expected_len {
return Ok(true);
}
let want = ((expected_len - produced) as usize).min(buf.len());
let n = match reader.read(&mut buf[..want]) {
Ok(0) => return Ok(false),
Ok(n) => n,
Err(_) => return Ok(false),
};
out.write_all(&buf[..n])?;
produced += n as u64;
}
}
#[cfg(test)]
mod tests {
use std::io::{Cursor, Read, Seek, SeekFrom};
use super::*;
fn bytes(data: &[u8]) -> Cursor<Vec<u8>> {
Cursor::new(data.to_vec())
}
struct PanickingReader(Cursor<Vec<u8>>);
impl Read for PanickingReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let backing_len = self.0.get_ref().len();
assert!(
buf.len() <= backing_len,
"oversized read requested: {} bytes against a {backing_len}-byte source \
— an allocation or read was sized from a declared length before it was bounded",
buf.len()
);
self.0.read(buf)
}
}
impl Seek for PanickingReader {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
self.0.seek(pos)
}
}
fn panicking_reader(len: usize) -> PanickingReader {
PanickingReader(Cursor::new(vec![0u8; len]))
}
struct NeverFinds;
impl SalvageScan for NeverFinds {
fn next_candidate(
&mut self,
_src: &mut dyn SeekRead,
_from: u64,
) -> Result<Option<Candidate>> {
Ok(None)
}
}
struct DeclaresLength(u64);
impl SalvageScan for DeclaresLength {
fn next_candidate(
&mut self,
_src: &mut dyn SeekRead,
from: u64,
) -> Result<Option<Candidate>> {
if from > 0 {
return Ok(None);
}
Ok(Some(Candidate {
offset: 0,
payload_start: 0,
meta: EntryMeta::file("absurd"),
declared_len: Some(self.0),
verifier: None,
available_len: None,
marked_deleted: false,
}))
}
}
struct DeclaresMoreThanIsThere {
declared: u64,
available: u64,
}
impl SalvageScan for DeclaresMoreThanIsThere {
fn next_candidate(
&mut self,
_src: &mut dyn SeekRead,
from: u64,
) -> Result<Option<Candidate>> {
if from > 0 {
return Ok(None);
}
Ok(Some(Candidate {
offset: 0,
payload_start: 0,
meta: EntryMeta::file("cut-short"),
declared_len: Some(self.declared),
verifier: Some(Verifier::Crc32(1)),
available_len: Some(self.available),
marked_deleted: false,
}))
}
}
#[test]
fn a_scanner_that_finds_nothing_yields_no_entries() {
let out = salvage_all(
&mut NeverFinds,
&mut bytes(&[0u8; 4096]),
&SalvagePolicy::default(),
);
assert!(
out.unwrap().entries.is_empty(),
"a scanner finding nothing must yield nothing"
);
}
#[test]
fn an_absurd_declared_length_is_refused_before_the_allocation_it_would_size() {
let mut scan = DeclaresLength(u64::MAX);
let out = salvage_all(
&mut scan,
&mut panicking_reader(4096),
&SalvagePolicy::default(),
)
.expect("an entry over the ceiling must not abort the run");
assert_eq!(out.entries.len(), 1, "the entry is still reported");
assert_eq!(
out.entries[0].status,
SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
needed: u64::MAX,
ceiling: MAX_SALVAGE_ENTRY,
})
);
}
#[test]
fn an_entry_over_the_ceiling_does_not_cost_the_entries_around_it() {
struct TwoCandidates;
impl SalvageScan for TwoCandidates {
fn next_candidate(
&mut self,
_src: &mut dyn SeekRead,
from: u64,
) -> Result<Option<Candidate>> {
let (offset, len, name) = match from {
0 => (0u64, 16u64, "small-first"),
1..=16 => (17u64, 4096u64, "huge-second"),
_ => return Ok(None),
};
Ok(Some(Candidate {
offset,
payload_start: offset,
meta: EntryMeta::file(name),
declared_len: Some(len),
verifier: Some(Verifier::Crc32(len as u32)),
available_len: None,
marked_deleted: false,
}))
}
}
let out = salvage_all(
&mut TwoCandidates,
&mut bytes(&[0u8; 8192]),
&SalvagePolicy {
max_entry: 64,
..SalvagePolicy::default()
},
)
.expect("one over-ceiling entry must never abort the run");
assert_eq!(out.entries.len(), 2);
assert_eq!(out.entries[0].status, SalvageStatus::Unattested);
assert_eq!(
out.entries[1].status,
SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
needed: 4096,
ceiling: 64,
})
);
}
#[test]
fn a_scanners_own_whole_entry_ceiling_narrows_the_policys() {
struct DecodesWhole;
impl SalvageScan for DecodesWhole {
fn next_candidate(
&mut self,
_src: &mut dyn SeekRead,
from: u64,
) -> Result<Option<Candidate>> {
if from > 0 {
return Ok(None);
}
Ok(Some(Candidate {
offset: 0,
payload_start: 0,
meta: EntryMeta::file("whole"),
declared_len: Some(2048),
verifier: Some(Verifier::Crc32(7)),
available_len: None,
marked_deleted: false,
}))
}
fn max_whole_entry(&self) -> u64 {
1024
}
fn verify(&self, _src: &mut dyn SeekRead, _c: &Candidate) -> Result<SalvageStatus> {
panic!("verify must not run for a candidate over the ceiling");
}
}
let out = salvage_all(
&mut DecodesWhole,
&mut panicking_reader(4096),
&SalvagePolicy::default(),
)
.expect("a scanner ceiling is a per-entry refusal, not a run failure");
assert_eq!(
out.entries[0].status,
SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
needed: 2048,
ceiling: 1024,
})
);
}
#[test]
fn a_declared_length_within_the_ceiling_is_collected_rather_than_refused() {
let mut scan = DeclaresLength(4096);
let out = salvage_all(
&mut scan,
&mut panicking_reader(4096),
&SalvagePolicy::default(),
)
.unwrap();
assert_eq!(out.entries.len(), 1);
let entry = &out.entries[0];
assert_eq!(entry.scan_position, 0);
assert_eq!(entry.offset, 0);
assert_eq!(entry.status, SalvageStatus::Unattested);
assert_eq!(entry.shadows, None);
assert_eq!(entry.collides_with, None);
}
#[test]
fn a_candidate_whose_payload_ran_out_is_still_collected() {
let mut scan = DeclaresMoreThanIsThere {
declared: 4000,
available: 1950,
};
let out = salvage_all(
&mut scan,
&mut panicking_reader(4096),
&SalvagePolicy::default(),
)
.unwrap();
assert_eq!(
out.entries.len(),
1,
"a header found but not completable is a fact worth reporting"
);
}
#[test]
fn a_truncated_candidate_is_bounded_by_what_is_there_not_by_what_it_claimed() {
let mut scan = DeclaresMoreThanIsThere {
declared: u64::MAX,
available: 2048,
};
let out = salvage_all(
&mut scan,
&mut panicking_reader(4096),
&SalvagePolicy::default(),
)
.expect("an absent tail must not abort a run that recovered real entries");
assert_eq!(out.entries.len(), 1);
}
#[test]
fn a_truncated_candidate_whose_available_bytes_exceed_the_ceiling_is_refused() {
let mut scan = DeclaresMoreThanIsThere {
declared: u64::MAX,
available: 4096,
};
let out = salvage_all(
&mut scan,
&mut panicking_reader(4096),
&SalvagePolicy {
max_entry: 1024,
..SalvagePolicy::default()
},
)
.expect("a bounded refusal is per-entry, never a run failure");
assert_eq!(
out.entries[0].status,
SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
needed: 4096,
ceiling: 1024,
}),
"an over-ceiling entry is unverified, not truncated: nobody read it"
);
}
#[test]
fn the_default_policy_is_recovery_biased() {
let policy = SalvagePolicy::default();
assert_eq!(policy.partial, PartialPolicy::Keep);
assert_eq!(policy.max_entry, MAX_SALVAGE_ENTRY);
assert!(!policy.strict);
}
struct ScriptedCandidates {
plan: Vec<(&'static str, Option<u64>, Option<Verifier>, SalvageStatus)>,
next: usize,
}
impl SalvageScan for ScriptedCandidates {
fn next_candidate(
&mut self,
_src: &mut dyn SeekRead,
_from: u64,
) -> Result<Option<Candidate>> {
if self.next >= self.plan.len() {
return Ok(None);
}
let offset = self.next as u64;
let (name, declared_len, verifier, _) = self.plan[self.next];
self.next += 1;
Ok(Some(Candidate {
offset,
payload_start: offset,
meta: EntryMeta::file(name),
declared_len,
verifier,
available_len: None,
marked_deleted: false,
}))
}
fn verify(&self, _src: &mut dyn SeekRead, candidate: &Candidate) -> Result<SalvageStatus> {
Ok(self.plan[candidate.offset as usize].3)
}
}
#[test]
fn verify_decides_each_entrys_status_not_a_hardcoded_default() {
let mut scan = ScriptedCandidates {
plan: vec![
(
"a",
Some(1),
Some(Verifier::Crc32(1)),
SalvageStatus::Intact,
),
("b", Some(1), None, SalvageStatus::Complete),
(
"c",
Some(1),
Some(Verifier::Crc32(2)),
SalvageStatus::Partial,
),
],
next: 0,
};
let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
.expect("scripted candidates must annotate cleanly");
assert_eq!(out.entries[0].status, SalvageStatus::Intact);
assert_eq!(out.entries[1].status, SalvageStatus::Complete);
assert_eq!(out.entries[2].status, SalvageStatus::Partial);
}
#[test]
fn a_later_candidate_sharing_an_earlier_verifier_is_marked_as_its_shadow() {
let mut scan = ScriptedCandidates {
plan: vec![
(
"dup",
Some(1),
Some(Verifier::Crc32(7)),
SalvageStatus::Intact,
),
(
"other",
Some(1),
Some(Verifier::Crc32(9)),
SalvageStatus::Intact,
),
(
"dup",
Some(1),
Some(Verifier::Crc32(7)),
SalvageStatus::Intact,
),
],
next: 0,
};
let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
.expect("scripted candidates must annotate cleanly");
assert_eq!(out.entries[0].shadows, None);
assert_eq!(out.entries[1].shadows, None);
assert_eq!(
out.entries[2].shadows,
Some(0),
"must point at the earliest matching position"
);
}
#[test]
fn candidates_with_no_verifier_never_shadow_each_other() {
let mut scan = ScriptedCandidates {
plan: vec![
("a", Some(1), None, SalvageStatus::Complete),
("a", Some(1), None, SalvageStatus::Complete),
],
next: 0,
};
let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
.expect("scripted candidates must annotate cleanly");
assert_eq!(out.entries[0].shadows, None);
assert_eq!(out.entries[1].shadows, None);
}
#[test]
fn a_matching_verifier_under_a_different_name_is_never_marked_as_a_shadow() {
let mut scan = ScriptedCandidates {
plan: vec![
(
"one.txt",
Some(4),
Some(Verifier::Crc32(42)),
SalvageStatus::Intact,
),
(
"two.txt",
Some(4),
Some(Verifier::Crc32(42)),
SalvageStatus::Intact,
),
],
next: 0,
};
let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
.expect("scripted candidates must annotate cleanly");
assert_eq!(
out.entries[1].shadows, None,
"same checksum, different name — not a shadow"
);
}
#[test]
fn a_zero_length_declared_verifier_never_shadows_even_under_a_matching_name() {
let mut scan = ScriptedCandidates {
plan: vec![
(
"empty.txt",
Some(0),
Some(Verifier::Crc32(0)),
SalvageStatus::Complete,
),
(
"empty.txt",
Some(0),
Some(Verifier::Crc32(0)),
SalvageStatus::Complete,
),
],
next: 0,
};
let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
.expect("scripted candidates must annotate cleanly");
assert_eq!(out.entries[0].shadows, None);
assert_eq!(
out.entries[1].shadows, None,
"a zero-byte checksum must never mark a shadow, even under an identical name"
);
assert_eq!(
out.entries[1].collides_with,
Some(0),
"the NAME still repeats, and that is a directly observed fact no checksum \
guard applies to"
);
}
#[test]
fn a_repeated_name_with_different_content_is_reported_as_a_collision() {
let mut scan = ScriptedCandidates {
plan: vec![
(
"dup.txt",
Some(21),
Some(Verifier::Crc32(7)),
SalvageStatus::Intact,
),
(
"other.txt",
Some(21),
Some(Verifier::Crc32(9)),
SalvageStatus::Intact,
),
(
"dup.txt",
Some(21),
Some(Verifier::Crc32(11)),
SalvageStatus::Intact,
),
],
next: 0,
};
let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
.expect("scripted candidates must annotate cleanly");
assert_eq!(out.entries[2].shadows, None, "the content does not agree");
assert_eq!(
out.entries[2].collides_with,
Some(0),
"but the name does, and that is the fact `list` warns about"
);
assert_eq!(out.entries[0].collides_with, None);
assert_eq!(out.entries[1].collides_with, None);
}
#[test]
fn a_proven_shadow_is_not_also_reported_as_a_name_collision() {
let mut scan = ScriptedCandidates {
plan: vec![
(
"dup.txt",
Some(21),
Some(Verifier::Crc32(7)),
SalvageStatus::Intact,
),
(
"dup.txt",
Some(21),
Some(Verifier::Crc32(7)),
SalvageStatus::Intact,
),
],
next: 0,
};
let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
.expect("scripted candidates must annotate cleanly");
assert_eq!(out.entries[1].shadows, Some(0));
assert_eq!(out.entries[1].collides_with, None);
}
#[test]
fn a_third_record_under_one_name_names_the_earliest_position() {
let mut scan = ScriptedCandidates {
plan: vec![
(
"dup",
Some(4),
Some(Verifier::Crc32(1)),
SalvageStatus::Intact,
),
(
"dup",
Some(4),
Some(Verifier::Crc32(2)),
SalvageStatus::Intact,
),
(
"dup",
Some(4),
Some(Verifier::Crc32(3)),
SalvageStatus::Intact,
),
],
next: 0,
};
let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
.expect("scripted candidates must annotate cleanly");
assert_eq!(out.entries[1].collides_with, Some(0));
assert_eq!(out.entries[2].collides_with, Some(0));
}
#[test]
fn distinct_names_are_never_reported_as_colliding() {
let mut scan = ScriptedCandidates {
plan: vec![
(
"one.txt",
Some(4),
Some(Verifier::Crc32(42)),
SalvageStatus::Intact,
),
(
"two.txt",
Some(4),
Some(Verifier::Crc32(42)),
SalvageStatus::Intact,
),
],
next: 0,
};
let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
.expect("scripted candidates must annotate cleanly");
assert_eq!(out.entries[0].collides_with, None);
assert_eq!(out.entries[1].collides_with, None);
}
#[test]
fn a_repeated_name_collides_even_with_no_verifier_to_compare() {
let mut scan = ScriptedCandidates {
plan: vec![
("same", Some(4), None, SalvageStatus::Complete),
("same", Some(4), None, SalvageStatus::Complete),
],
next: 0,
};
let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
.expect("scripted candidates must annotate cleanly");
assert_eq!(
out.entries[1].shadows, None,
"no verifier, so nothing proves they are copies"
);
assert_eq!(
out.entries[1].collides_with,
Some(0),
"but the name repeats, which needs no verifier to observe"
);
}
#[test]
fn a_new_candidate_claims_nothing_it_was_not_told() {
let c = Candidate::new(7, 42, EntryMeta::file("probe"));
assert_eq!(c.offset, 7);
assert_eq!(c.payload_start, 42);
assert_eq!(c.declared_len, None, "no length was declared");
assert_eq!(c.verifier, None, "nothing can prove the content");
assert_eq!(
c.available_len, None,
"a shortfall is MEASURED, never assumed"
);
assert!(!c.marked_deleted, "no record is deleted until one says so");
}
#[test]
fn each_candidate_setter_reaches_its_own_field() {
let c = Candidate::new(0, 0, EntryMeta::file("probe"))
.with_declared_len(Some(9))
.with_verifier(Some(Verifier::Crc16(0xbeef)))
.with_available_len(Some(4))
.with_marked_deleted(true);
assert_eq!(c.declared_len, Some(9));
assert_eq!(c.verifier, Some(Verifier::Crc16(0xbeef)));
assert_eq!(c.available_len, Some(4));
assert!(c.marked_deleted);
}
#[test]
fn a_new_salvaged_entry_carries_no_annotations() {
let e = SalvagedEntry::new(3, 7, 42, EntryMeta::file("probe"), SalvageStatus::Complete);
assert_eq!(e.scan_position, 3);
assert_eq!(e.offset, 7);
assert_eq!(e.payload_start, 42);
assert_eq!(e.status, SalvageStatus::Complete);
assert_eq!(e.shadows, None);
assert_eq!(e.collides_with, None);
assert!(!e.marked_deleted);
let annotated = SalvagedEntry::new(0, 0, 0, EntryMeta::file("p"), SalvageStatus::Intact)
.with_shadows(Some(1))
.with_collides_with(Some(2))
.with_marked_deleted(true);
assert_eq!(annotated.shadows, Some(1));
assert_eq!(annotated.collides_with, Some(2));
assert!(annotated.marked_deleted);
}
#[test]
fn describe_sightings_groups_by_shape_and_bounds_what_it_lists() {
assert_eq!(describe_sightings(&[]), None);
let f = FormatId::new("fmt");
let mut seen: Vec<Sighting> = (0..10u64)
.map(|i| Sighting::new(f, i * 512, "a(s)"))
.collect();
seen.insert(1, Sighting::new(f, 7, "b(s)"));
let note = describe_sightings(&seen).unwrap();
assert!(
note.contains("10 fmt a(s) at offset(s) 0, 512, 1024, 1536, 2048, 2560, 3072, 3584, and 2 more; and 1 fmt b(s) at offset(s) 7"),
"{note}"
);
assert!(note.contains("`stuffr list`"), "{note}");
}
#[test]
fn describe_walk_stop_says_unreachable_for_a_hole_and_complete_for_a_cut() {
let f = FormatId::new("fmt");
let hole = describe_walk_stop(&WalkStop::new(
f,
1234,
5000,
WalkStopKind::Unreadable,
"bad field",
));
for needle in [
"offset 1234",
"5000 bytes",
"bad field",
"unreachable by construction",
"not absent",
"fmt",
] {
assert!(hole.contains(needle), "missing {needle:?}: {hole}");
}
let cut = describe_walk_stop(&WalkStop::new(f, 90, 1, WalkStopKind::CutShort, "eof"));
assert!(cut.contains("1 byte into the record at offset 90"), "{cut}");
assert!(cut.contains("cut short"), "{cut}");
assert!(!cut.contains("unreachable"), "{cut}");
let shape = describe_walk_stop(&WalkStop::new(
f,
8,
600,
WalkStopKind::UnsupportedShape,
"the symbol table",
));
assert!(shape.contains("the symbol table"), "{shape}");
assert!(shape.contains("not evidence of damage"), "{shape}");
assert!(!shape.contains("unreachable by construction"), "{shape}");
}
}