use std::collections::HashMap;
use std::io::{self, Read, SeekFrom, 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, Hash)]
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,
pub kind: SightingKind,
pub more: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum SightingKind {
#[default]
Ungateable,
WorkBounded,
VerdictBounded,
}
impl Sighting {
pub fn new(format: FormatId, offset: u64, shape: &'static str) -> Self {
Self {
format,
offset,
shape,
kind: SightingKind::Ungateable,
more: 0,
}
}
#[must_use]
pub fn with_more(self, more: u64) -> Self {
Self { more, ..self }
}
pub fn work_bounded(format: FormatId, offset: u64, shape: &'static str) -> Self {
Self {
kind: SightingKind::WorkBounded,
..Self::new(format, offset, shape)
}
}
pub fn verdict_bounded(format: FormatId, offset: u64, shape: &'static str) -> Self {
Self {
kind: SightingKind::VerdictBounded,
..Self::new(format, offset, shape)
}
}
}
#[derive(Debug, Clone)]
pub struct SightingLog {
cap: usize,
groups: Vec<LogGroup>,
}
#[derive(Debug, Clone)]
struct LogGroup {
key: (FormatId, &'static str, SightingKind),
stored: Vec<Sighting>,
total: u64,
}
impl SightingLog {
pub const DEFAULT_CAP: usize = 64;
pub fn new() -> Self {
Self::with_cap(Self::DEFAULT_CAP)
}
pub fn with_cap(cap: usize) -> Self {
Self {
cap: cap.max(1),
groups: Vec::new(),
}
}
pub fn push(&mut self, sighting: Sighting) {
let key = (sighting.format, sighting.shape, sighting.kind);
let idx = match self.groups.iter().position(|g| g.key == key) {
Some(i) => i,
None => {
self.groups.push(LogGroup {
key,
stored: Vec::new(),
total: 0,
});
self.groups.len() - 1
}
};
let g = &mut self.groups[idx];
g.total = g.total.saturating_add(1).saturating_add(sighting.more);
if g.stored.len() < self.cap {
g.stored.push(sighting);
}
}
pub fn count(&self, format: FormatId, shape: &'static str, kind: SightingKind) -> u64 {
self.groups
.iter()
.find(|g| g.key == (format, shape, kind))
.map_or(0, |g| g.total)
}
pub fn is_empty(&self) -> bool {
self.groups.is_empty()
}
pub fn into_sightings(self) -> Vec<Sighting> {
let mut out = Vec::new();
for mut g in self.groups {
let stored_weight: u64 = g
.stored
.iter()
.fold(0u64, |a, s| a.saturating_add(1).saturating_add(s.more));
let rest = g.total.saturating_sub(stored_weight);
if let Some(last) = g.stored.last_mut() {
last.more = last.more.saturating_add(rest);
}
out.extend(g.stored);
}
out
}
}
impl Default for SightingLog {
fn default() -> Self {
Self::new()
}
}
const SIGHTING_OFFSETS_SHOWN: usize = 8;
pub fn describe_sightings(sightings: &[Sighting]) -> Option<String> {
describe(sightings, true)
}
fn describe(sightings: &[Sighting], list_clause: bool) -> Option<String> {
let tail = if list_clause {
"; `stuffr list` and `stuffr unpack` may read these headers normally"
} else {
""
};
let of_kind = |kind: SightingKind| -> Vec<&Sighting> {
sightings.iter().filter(|s| s.kind == kind).collect()
};
let ungateable = of_kind(SightingKind::Ungateable);
let bounded = of_kind(SightingKind::WorkBounded);
let verdicts = of_kind(SightingKind::VerdictBounded);
let mut sentences = Vec::new();
if !ungateable.is_empty() {
sentences.push(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",
sighting_groups(&ungateable)
));
}
if !bounded.is_empty() {
sentences.push(format!(
"this build's salvage scanner bounds the work it spends on extension chains, and \
did not read the chains in front of {} — so whatever those chains describe is not \
listed or recovered there, though the scan went on past them{tail}",
sighting_groups(&bounded)
));
}
if !verdicts.is_empty() {
sentences.push(format!(
"this build's salvage scanner bounds the work it spends judging candidate headers \
across the whole scan, and did not judge {} — so whatever those headers \
describe is not listed or recovered there, though the scan went on past them{tail}",
sighting_groups(&verdicts)
));
}
(!sentences.is_empty()).then(|| sentences.join("; and separately, "))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RefusalSays {
ListReadsThem,
NothingAboutList,
}
pub fn refusal_with_bounded_notes(
refusal: String,
sightings: Vec<Sighting>,
says: RefusalSays,
) -> String {
let bounded: Vec<Sighting> = sightings
.into_iter()
.filter(|s| s.kind != SightingKind::Ungateable)
.collect();
match describe(&bounded, says == RefusalSays::NothingAboutList) {
Some(note) => format!("{refusal}; and separately, {note}"),
None => refusal,
}
}
fn sighting_groups(sightings: &[&Sighting]) -> String {
let mut groups: Vec<((FormatId, &'static str), Vec<u64>, u64)> = Vec::new();
for s in sightings {
let key = (s.format, s.shape);
match groups.iter_mut().find(|(k, _, _)| *k == key) {
Some((_, offsets, extra)) => {
offsets.push(s.offset);
*extra = extra.saturating_add(s.more);
}
None => groups.push((key, vec![s.offset], s.more)),
}
}
let parts: Vec<String> = groups
.iter()
.map(|((format, shape), offsets, extra)| {
let count = offsets.len() as u64 + extra;
let mut shown: Vec<String> = offsets
.iter()
.take(SIGHTING_OFFSETS_SHOWN)
.map(u64::to_string)
.collect();
if count > SIGHTING_OFFSETS_SHOWN as u64 {
shown.push(format!("and {} more", count - shown.len() as u64));
}
format!("{count} {format} {shape} at offset(s) {}", shown.join(", "))
})
.collect();
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,
}
}
}
#[derive(Debug, Default)]
pub struct ForwardSearch {
window: Vec<u8>,
window_start: u64,
}
impl ForwardSearch {
pub const FIRST_CHUNK: usize = 64;
pub const MAX_CHUNK: usize = 64 * 1024;
pub fn new() -> Self {
Self::default()
}
pub fn find(
&mut self,
src: &mut dyn SeekRead,
from: u64,
file_len: u64,
sig_len: usize,
matches: impl FnMut(&[u8]) -> bool,
) -> io::Result<Option<u64>> {
self.find_to_eof(src, from, file_len, sig_len, sig_len, matches)
}
pub fn find_to_eof(
&mut self,
src: &mut dyn SeekRead,
from: u64,
file_len: u64,
sig_len: usize,
min_len: usize,
mut matches: impl FnMut(&[u8]) -> bool,
) -> io::Result<Option<u64>> {
debug_assert!(1 <= min_len && min_len <= sig_len);
if min_len == 0 || file_len.saturating_sub(from) < min_len as u64 {
return Ok(None);
}
let kept_end = self.window_start + self.window.len() as u64;
if from >= self.window_start && from < kept_end {
self.window.drain(..(from - self.window_start) as usize);
let fits = (file_len - from).min(self.window.len() as u64);
self.window.truncate(fits as usize);
} else {
self.window.clear();
}
self.window_start = from;
let mut chunk = Self::FIRST_CHUNK.max(2 * sig_len - 1);
let mut at_eof = false;
loop {
if self.window.len() >= sig_len {
let last = self.window.len() - sig_len;
if let Some(at) = (0..=last).find(|&i| matches(&self.window[i..i + sig_len])) {
return Ok(Some(self.window_start + at as u64));
}
let tested = last + 1;
self.window_start += tested as u64;
self.window.drain(..tested);
}
let end = self.window_start + self.window.len() as u64;
if at_eof || end >= file_len {
let short = self.window.len().saturating_sub(min_len - 1);
let tail = (0..short).find(|&i| matches(&self.window[i..]));
return Ok(tail.map(|at| self.window_start + at as u64));
}
let old = self.window.len();
let want = (chunk as u64).min(file_len - end) as usize;
src.seek(SeekFrom::Start(end))?;
self.window.resize(old + want, 0);
let n = src.read(&mut self.window[old..])?;
self.window.truncate(old + n);
if n == 0 {
at_eof = true;
} else if n == want {
chunk = (chunk * 2).min(Self::MAX_CHUNK);
}
}
}
}
#[derive(Debug)]
pub struct ScanBudget {
limit: u64,
spent: u64,
}
impl ScanBudget {
pub const PER_INPUT_BYTE: u64 = 2;
pub const SLACK: u64 = 32 * 1024;
pub fn for_input(file_len: u64) -> Self {
Self {
limit: file_len
.saturating_mul(Self::PER_INPUT_BYTE)
.saturating_add(Self::SLACK),
spent: 0,
}
}
pub fn try_spend(&mut self, bytes: u64) -> bool {
match self.spent.checked_add(bytes) {
Some(total) if total <= self.limit => {
self.spent = total;
true
}
_ => false,
}
}
pub fn spent(&self) -> u64 {
self.spent
}
}
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: HashMap<(String, Option<u64>, Verifier), usize> = HashMap::new();
let mut names_seen: HashMap<String, usize> = HashMap::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.get(&(candidate.meta.name.clone(), declared_len, v))
.copied()
})
};
if !is_degenerate
&& shadows.is_none()
&& let Some(v) = verifier
{
seen.entry((candidate.meta.name.clone(), declared_len, v))
.or_insert(scan_position);
}
let earliest_with_name = names_seen.get(&candidate.meta.name).copied();
let collides_with = if shadows.is_some() {
None
} else {
earliest_with_name
};
if earliest_with_name.is_none() {
names_seen.insert(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"
);
}
fn find_all(data: &[u8], sig: &[u8]) -> Vec<u64> {
let mut search = ForwardSearch::new();
let mut src = Cursor::new(data.to_vec());
let len = data.len() as u64;
let mut from = 0;
let mut hits = Vec::new();
while let Some(at) = search
.find(&mut src, from, len, sig.len(), |w| w == sig)
.unwrap()
{
hits.push(at);
from = at + 1;
}
hits
}
fn naive(data: &[u8], sig: &[u8]) -> Vec<u64> {
(0..=data.len().saturating_sub(sig.len()))
.filter(|&i| data.len() >= sig.len() && &data[i..i + sig.len()] == sig)
.map(|i| i as u64)
.collect()
}
#[test]
fn forward_search_finds_what_a_naive_scan_finds() {
let sig = b"070701";
let mut data = vec![b'x'; 200_000];
for at in [0usize, 62, 63, 127, 255, 4093, 65_533, 131_070, 199_994] {
data[at..at + sig.len()].copy_from_slice(sig);
}
assert_eq!(find_all(&data, sig), naive(&data, sig));
}
#[test]
fn forward_search_past_eof_and_on_short_input_is_none() {
let mut search = ForwardSearch::new();
let mut src = Cursor::new(b"0707".to_vec());
assert_eq!(search.find(&mut src, 0, 4, 6, |_| true).unwrap(), None);
assert_eq!(search.find(&mut src, 9, 4, 1, |_| true).unwrap(), None);
}
struct Trickle(Cursor<Vec<u8>>);
impl Read for Trickle {
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
let n = out.len().min(7);
self.0.read(&mut out[..n])
}
}
impl Seek for Trickle {
fn seek(&mut self, to: SeekFrom) -> std::io::Result<u64> {
self.0.seek(to)
}
}
#[test]
fn forward_search_survives_short_reads() {
let mut data = vec![0u8; 5_000];
data[4_321..4_327].copy_from_slice(b"070701");
let mut search = ForwardSearch::new();
let mut src = Trickle(Cursor::new(data));
assert_eq!(
search
.find(&mut src, 0, 5_000, 6, |w| w == b"070701")
.unwrap(),
Some(4_321)
);
}
#[test]
fn forward_search_no_match_reads_each_byte_once_and_ends_at_eof() {
use crate::testing::CountingSource;
let data = vec![b'x'; 200_000];
let mut src = CountingSource::new(Cursor::new(data));
let mut search = ForwardSearch::new();
assert_eq!(
search
.find(&mut src, 0, 200_000, 6, |w| w == b"070701")
.unwrap(),
None
);
assert_eq!(src.bytes_read(), 200_000);
}
#[test]
fn forward_search_no_match_through_short_reads_ends_at_eof() {
let mut search = ForwardSearch::new();
let mut src = Trickle(Cursor::new(vec![b'x'; 200_000]));
assert_eq!(
search
.find(&mut src, 0, 200_000, 6, |w| w == b"070701")
.unwrap(),
None
);
}
#[test]
fn forward_search_on_a_truncated_source_ends_at_eof() {
let mut search = ForwardSearch::new();
let mut src = Cursor::new(vec![b'x'; 1_000]);
assert_eq!(
search
.find(&mut src, 0, 50_000, 6, |w| w == b"070701")
.unwrap(),
None
);
}
#[test]
fn forward_search_never_looks_past_an_understated_file_len() {
let mut data = vec![b'x'; 10_000];
data[5_000..5_006].copy_from_slice(b"070701");
let mut search = ForwardSearch::new();
let mut src = Cursor::new(data);
assert_eq!(
search
.find(&mut src, 0, 4_000, 6, |w| w == b"070701")
.unwrap(),
None
);
assert_eq!(
search
.find(&mut src, 0, 5_003, 6, |w| w == b"070701")
.unwrap(),
None
);
assert_eq!(
search
.find(&mut src, 0, 5_006, 6, |w| w == b"070701")
.unwrap(),
Some(5_000)
);
}
#[test]
fn forward_search_finds_a_hit_straddling_large_chunk_boundaries() {
let sig = b"070701";
let mut ends = Vec::new();
let (mut chunk, mut at) = (ForwardSearch::FIRST_CHUNK, 0usize);
while ends.len() < 14 {
at += chunk;
ends.push(at);
chunk = (chunk * 2).min(ForwardSearch::MAX_CHUNK);
}
let (mut sizes, mut chunk) = (Vec::new(), ForwardSearch::FIRST_CHUNK);
for _ in 0..ends.len() {
sizes.push(chunk);
chunk = (chunk * 2).min(ForwardSearch::MAX_CHUNK);
}
let doubling = ends[sizes
.iter()
.position(|&c| c == ForwardSearch::MAX_CHUNK / 2)
.unwrap()];
let first_max = ends[sizes
.iter()
.position(|&c| c == ForwardSearch::MAX_CHUNK)
.unwrap()];
for boundary in [doubling, first_max, first_max + ForwardSearch::MAX_CHUNK] {
for start in [boundary - 5, boundary - 3, boundary - 1] {
let mut data = vec![b'x'; boundary + 100];
data[start..start + sig.len()].copy_from_slice(sig);
let mut search = ForwardSearch::new();
let mut src = Cursor::new(data.clone());
let found = search
.find(&mut src, 0, data.len() as u64, sig.len(), |w| w == sig)
.unwrap();
assert_eq!(found, Some(start as u64), "boundary {boundary}");
}
}
}
#[test]
fn a_nearby_hit_reads_only_a_few_bytes() {
use crate::testing::CountingSource;
let mut data = vec![0u8; 1 << 20];
data[10..16].copy_from_slice(b"070701");
let mut src = CountingSource::new(Cursor::new(data));
let mut search = ForwardSearch::new();
assert_eq!(
search
.find(&mut src, 0, 1 << 20, 6, |w| w == b"070701")
.unwrap(),
Some(10)
);
assert!(
src.bytes_read() <= ForwardSearch::FIRST_CHUNK as u64,
"{}",
src.bytes_read()
);
}
#[test]
fn a_long_signature_s_first_read_tests_sig_len_offsets() {
use crate::testing::CountingSource;
let sig = [0xA5u8; 512];
let mut data = vec![0u8; 1 << 20];
data[300..812].copy_from_slice(&sig);
let mut src = CountingSource::new(Cursor::new(data));
let mut search = ForwardSearch::new();
assert_eq!(
search
.find(&mut src, 0, 1 << 20, 512, |w| w == sig)
.unwrap(),
Some(300)
);
assert_eq!(src.bytes_read(), 2 * 512 - 1);
}
#[test]
fn dense_hits_read_each_byte_about_once() {
use crate::testing::CountingSource;
let sig = b"070701";
let mut data = vec![b'x'; 1 << 20];
for at in (0..data.len() - sig.len()).step_by(7) {
data[at..at + sig.len()].copy_from_slice(sig);
}
let len = data.len() as u64;
let mut src = CountingSource::new(Cursor::new(data.clone()));
let mut search = ForwardSearch::new();
let (mut from, mut hits) = (0, Vec::new());
while let Some(at) = search
.find(&mut src, from, len, sig.len(), |w| w == sig)
.unwrap()
{
hits.push(at);
src.seek(SeekFrom::Start(at)).unwrap();
let mut header = [0u8; 6];
src.read_exact(&mut header).unwrap();
from = at + 1;
}
assert_eq!(hits, naive(&data, sig));
let header_reads = 6 * hits.len() as u64;
assert!(
src.bytes_read() - header_reads <= len + ForwardSearch::MAX_CHUNK as u64,
"searches read {} bytes of a {len}-byte input",
src.bytes_read() - header_reads
);
}
#[test]
fn kept_bytes_never_change_an_answer() {
let sig = b"070701";
let mut data = vec![b'x'; 300_000];
for at in [5usize, 70, 4_000, 70_000, 250_000, 299_994] {
data[at..at + sig.len()].copy_from_slice(sig);
}
let len = data.len() as u64;
let mut kept = ForwardSearch::new();
let mut src = Cursor::new(data.clone());
for from in [
0u64, 6, 3, 71, 60, 4_001, 200_000, 69_000, 299_995, 250_001, 0,
] {
for file_len in [len, 250_003, 70_006, 4_005] {
let fresh = ForwardSearch::new()
.find(&mut Cursor::new(data.clone()), from, file_len, 6, |w| {
w == sig
})
.unwrap();
let got = kept
.find(&mut src, from, file_len, 6, |w| w == sig)
.unwrap();
assert_eq!(got, fresh, "from {from}, file_len {file_len}");
}
}
}
#[test]
fn find_to_eof_offers_the_tail_what_is_left() {
let data: Vec<u8> = (0..100u8).collect();
let mut offered = Vec::new();
let found = ForwardSearch::new()
.find_to_eof(&mut Cursor::new(data.clone()), 10, 100, 20, 3, |w| {
offered.push((w[0], w.len()));
false
})
.unwrap();
assert_eq!(found, None);
let expected: Vec<(u8, usize)> = (10u8..=97)
.map(|at| (at, 20.min(100 - at as usize)))
.collect();
assert_eq!(offered, expected);
let at = ForwardSearch::new()
.find_to_eof(&mut Cursor::new(data), 0, 100, 20, 1, |w| w[0] == 95)
.unwrap();
assert_eq!(at, Some(95));
}
#[test]
fn annotate_candidates_reports_the_earliest_position_at_scale() {
let names: Vec<&'static str> = (0..3_000)
.map(|i| &*Box::leak(format!("f{i}").into_boxed_str()))
.collect();
let mut plan = Vec::new();
for i in 0..3_000usize {
let name = if i % 10 == 9 {
names[(i % 100) / 10 * 10]
} else {
names[i]
};
let declared = if i % 20 == 19 { Some(8) } else { Some(4) };
let crc = if i % 10 == 9 && (i / 10) % 2 == 0 {
8
} else {
7
};
plan.push((
name,
declared,
Some(Verifier::Crc32(crc)),
SalvageStatus::Intact,
));
}
let keys: Vec<_> = plan.iter().map(|p| (p.0, p.1, p.2)).collect();
let mut scan = ScriptedCandidates { plan, next: 0 };
let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
.expect("scripted candidates must annotate cleanly");
assert_eq!(out.entries.len(), 3_000);
let mut first_triple = HashMap::new();
let mut first_name = HashMap::new();
for (i, k) in keys.iter().enumerate() {
first_triple.entry(*k).or_insert(i);
first_name.entry(k.0).or_insert(i);
}
for (i, e) in out.entries.iter().enumerate() {
let same_triple = first_triple[&keys[i]];
let same_name = first_name[keys[i].0];
let (want_shadow, want_collide) = if same_triple != i {
(Some(same_triple), None)
} else if same_name != i {
(None, Some(same_name))
} else {
(None, None)
};
assert_eq!(e.shadows, want_shadow, "shadows at {i}");
assert_eq!(e.collides_with, want_collide, "collides_with at {i}");
}
let shadowed = out.entries.iter().filter(|e| e.shadows.is_some()).count();
let collided = out
.entries
.iter()
.filter(|e| e.collides_with.is_some())
.count();
assert!(shadowed > 0 && collided > 0, "{shadowed} {collided}");
}
#[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 a_log_stores_at_most_the_cap_per_group_and_counts_the_rest() {
let mut log = SightingLog::with_cap(4);
for off in 0..10u64 {
log.push(Sighting::verdict_bounded(FormatId::new("zip"), off, "x"));
}
log.push(Sighting::new(FormatId::new("zip"), 99, "y"));
assert_eq!(
log.count(FormatId::new("zip"), "x", SightingKind::VerdictBounded),
10
);
let out = log.into_sightings();
let x: Vec<_> = out.iter().filter(|s| s.shape == "x").collect();
assert_eq!(x.iter().map(|s| s.offset).collect::<Vec<_>>(), [0, 1, 2, 3]);
assert_eq!(x.iter().map(|s| s.more).sum::<u64>(), 6);
assert_eq!(x.last().unwrap().more, 6);
assert_eq!(out.iter().filter(|s| s.shape == "y").count(), 1);
}
#[test]
fn capping_keeps_the_printed_sentence_identical() {
let all: Vec<Sighting> = (0..500u64)
.map(|o| Sighting::verdict_bounded(FormatId::new("zip"), o * 3, "x"))
.chain((0..20u64).map(|o| Sighting::new(FormatId::new("tar"), o, "y")))
.collect();
let mut log = SightingLog::new();
for s in all.clone() {
log.push(s);
}
assert_eq!(
describe_sightings(&log.into_sightings()),
describe_sightings(&all)
);
}
#[test]
fn capping_keeps_the_sentence_identical_across_random_inputs() {
let mut state = 0x9E37_79B9_7F4A_7C15u64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
let formats = [FormatId::new("zip"), FormatId::new("tar")];
let shapes = ["x", "y", "z"];
let kinds = [
SightingKind::Ungateable,
SightingKind::WorkBounded,
SightingKind::VerdictBounded,
];
for _ in 0..1000 {
let len = (next() % 300) as usize;
let all: Vec<Sighting> = (0..len)
.map(|_| {
let r = next();
let s = Sighting::new(
formats[(r % 2) as usize],
(r >> 8) % 100_000,
shapes[((r >> 2) % 3) as usize],
);
Sighting {
kind: kinds[((r >> 4) % 3) as usize],
..s
}
})
.collect();
for cap in [8, 9, 64] {
let mut log = SightingLog::with_cap(cap);
for s in all.clone() {
log.push(s);
}
assert_eq!(
describe_sightings(&log.into_sightings()),
describe_sightings(&all),
"cap {cap}, {len} sightings"
);
}
}
}
#[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_sightings_words_a_work_bounded_sighting_apart() {
let f = FormatId::new("fmt");
let old = describe_sightings(&[Sighting::new(f, 0, "a(s)")]).unwrap();
assert_eq!(
old,
"this build's salvage scanner saw 1 fmt a(s) at offset(s) 0 — 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"
);
let bounded = describe_sightings(&[Sighting::work_bounded(f, 9, "b(s)")]).unwrap();
assert!(bounded.contains("1 fmt b(s) at offset(s) 9"), "{bounded}");
assert!(!bounded.contains("no gate for"), "{bounded}");
assert!(bounded.contains("bounds the work"), "{bounded}");
assert!(bounded.contains("the scan went on past them"), "{bounded}");
assert!(!bounded.contains("forged"), "{bounded}");
assert!(!bounded.contains("SCAN that stops"), "{bounded}");
let both = describe_sightings(&[
Sighting::work_bounded(f, 9, "b(s)"),
Sighting::new(f, 0, "a(s)"),
])
.unwrap();
assert_eq!(both, format!("{old}; and separately, {bounded}"));
}
#[test]
fn describe_sightings_words_a_verdict_bounded_sighting_apart() {
let f = FormatId::new("fmt");
let old = describe_sightings(&[Sighting::new(f, 0, "a(s)")]).unwrap();
let bounded = describe_sightings(&[Sighting::work_bounded(f, 9, "b(s)")]).unwrap();
let verdict = describe_sightings(&[Sighting::verdict_bounded(f, 7, "c(s)")]).unwrap();
assert_eq!(
verdict,
"this build's salvage scanner bounds the work it spends judging candidate headers \
across the whole scan, and did not judge 1 fmt c(s) at offset(s) 7 — so \
whatever those headers describe is not listed or recovered there, though the scan \
went on past them; `stuffr list` and `stuffr unpack` may read these headers normally"
);
assert!(!verdict.contains("extension chains"), "{verdict}");
let all = describe_sightings(&[
Sighting::verdict_bounded(f, 7, "c(s)"),
Sighting::work_bounded(f, 9, "b(s)"),
Sighting::new(f, 0, "a(s)"),
])
.unwrap();
assert_eq!(
all,
format!("{old}; and separately, {bounded}; and separately, {verdict}")
);
}
#[test]
fn a_scan_budget_spends_to_its_exact_limit_and_refuses_past_it() {
let limit = 2 * 1000 + 32 * 1024;
let mut budget = ScanBudget::for_input(1000);
assert!(budget.try_spend(limit - 10));
assert_eq!(budget.spent(), limit - 10);
assert!(!budget.try_spend(11), "one byte past the limit");
assert_eq!(budget.spent(), limit - 10, "a refusal charges nothing");
assert!(budget.try_spend(10), "exactly to the limit");
assert_eq!(budget.spent(), limit);
assert!(!budget.try_spend(1));
assert_eq!(budget.spent(), limit);
}
#[test]
fn a_scan_budget_takes_zero_and_refuses_an_overflowing_charge() {
let mut budget = ScanBudget::for_input(0);
assert!(budget.try_spend(0));
assert_eq!(budget.spent(), 0);
assert!(budget.try_spend(ScanBudget::SLACK));
assert!(budget.try_spend(0), "zero fits a spent budget");
assert!(!budget.try_spend(u64::MAX));
assert_eq!(budget.spent(), ScanBudget::SLACK);
let mut huge = ScanBudget::for_input(u64::MAX);
assert!(huge.try_spend(u64::MAX), "the limit saturates at u64::MAX");
assert!(!huge.try_spend(1), "and a sum past it does not wrap");
assert_eq!(huge.spent(), u64::MAX);
}
#[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}");
}
}