Skip to main content

tsaur_core/
pack.rs

1//! Packing: enumerate → (invert containers) → chunk → dedup → group into blocks → compress → seal
2//! → stream to disk. Memory stays bounded by the block size and the chunk index: file contents are
3//! chunked with a streaming FastCDC reader, unique chunks are compressed in blocks and written as
4//! soon as a batch is ready, and only container files (which need preflate) are held in memory.
5//!
6//! The output is deterministic for identical inputs and options when `keep_mtime` and `timestamp`
7//! are off and no encryption is used (encryption adds a random archive key).
8
9use crate::canonical;
10use crate::chunk::{self, ChunkParams, Hash};
11use crate::codec::{self, CodecChoice, CodecOptions};
12use crate::container;
13use crate::crypto::{self, ArchiveKey, KdfParams};
14use crate::error::{Error, Result};
15use crate::format::{self, section, Header, SectionEntry, SignatureBlock};
16use crate::manifest::{BlobRecord, ChunkIndex, ChunkRef, Derived, Entry, Limits, Manifest, RefArchive};
17use crate::paths;
18use crate::read::Reader;
19use fastcdc::v2020::{Normalization, StreamCDC};
20use rayon::prelude::*;
21use serde::Serialize;
22use serde_bytes::ByteBuf;
23use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
24use std::fs::File;
25use std::io::{BufReader, BufWriter, Write};
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28
29#[derive(Clone, Debug)]
30pub struct PackOptions {
31    pub chunk: ChunkParams,
32    /// 0 = granular (one chunk per blob, best random access); otherwise consecutive unique chunks
33    /// are compressed together in blocks of up to this many bytes (default 1 MiB).
34    pub block_size: u32,
35    pub level: i32,
36    /// Codec(s) the packer may use per block.
37    pub codec: CodecChoice,
38    /// Codec-choice effort for `CodecChoice::Best`, 1..=5 (`codec::CodecOptions::effort`): 1 = zstd
39    /// only, 2 = zstd or xz decided on a sample of each block, 3 = zstd, xz or PPMd decided on a
40    /// sample, 4 = 3 plus an xz check when PPMd wins, 5 = every codec on every block (default).
41    /// Blocks of at most 128 KiB always run the full trial. Not recorded in the archive.
42    pub effort: u8,
43    /// Train a zstd dictionary on the first `dict_warmup` bytes of unique chunks (granular mode only).
44    pub train_dict: bool,
45    pub dict_size: usize,
46    pub dict_warmup: usize,
47    pub container_aware: bool,
48    pub password: Option<String>,
49    pub kdf: KdfParams,
50    pub sign_seed: Option<[u8; 32]>,
51    pub keep_mtime: bool,
52    pub timestamp: bool,
53    pub profile: String,
54    pub limits: Limits,
55    /// Blocks compressed in parallel per batch (0 = number of CPU threads).
56    pub batch: usize,
57    /// Reference archives: chunks already present in them are referenced, not stored.
58    pub references: Vec<PathBuf>,
59    /// Hybrid X25519 + ML-KEM-768 recipients (public key bytes); each gets its own stanza.
60    pub recipients: Vec<crypto::hybrid::Recipient>,
61    /// Also store canonical views (DOCX -> Markdown, PDF -> text) as derived entries under
62    /// `.tsaur/views/` (hybrid fidelity: originals stay bit-exact, views are pre-computed).
63    pub canonical: bool,
64    /// Delta coding: blocks of an entry that looks like a new version of an earlier entry (same
65    /// path in a reference archive, or a similarly named earlier entry of this archive) are also
66    /// tried with zstd against that earlier content as dictionary; the smaller encoding wins.
67    pub delta: bool,
68    /// Recompress JPEG files and PDF `DCTDecode` images with Lepton when the build has it.
69    /// `false` (`pack --no-lepton`) stores them as they are, so that every build, including one
70    /// without the `lepton` feature, can read the archive.
71    pub jpeg_recompression: bool,
72}
73
74impl Default for PackOptions {
75    fn default() -> Self {
76        Self {
77            chunk: ChunkParams::P2P,
78            block_size: 1 << 20,
79            level: 19,
80            codec: CodecChoice::Best,
81            effort: codec::EFFORT_DEFAULT,
82            train_dict: true,
83            dict_size: 110 * 1024,
84            dict_warmup: 8 << 20,
85            container_aware: true,
86            password: None,
87            kdf: KdfParams::default(),
88            sign_seed: None,
89            keep_mtime: false,
90            timestamp: false,
91            profile: "block".into(),
92            limits: Limits::default(),
93            batch: 0,
94            references: Vec::new(),
95            recipients: Vec::new(),
96            canonical: false,
97            delta: true,
98            jpeg_recompression: true,
99        }
100    }
101}
102
103#[derive(Clone, Debug, Serialize)]
104pub struct PackReport {
105    pub archive_bytes: u64,
106    pub input_bytes: u64,
107    pub logical_bytes: u64,
108    pub unique_bytes: u64,
109    pub chunks_total: usize,
110    pub chunks_unique: usize,
111    pub blobs: usize,
112    pub dict_bytes: usize,
113    pub codec_hist: BTreeMap<String, u32>,
114    /// Blocks whose filter and codec were decided on a sample (`--effort 2..4`, blocks above
115    /// 128 KiB); 0 at the default effort.
116    pub blocks_sampled: usize,
117    pub entries: usize,
118    pub containers_exploded: usize,
119    pub containers_fallback: usize,
120    pub root: String,
121    pub encrypted: bool,
122    pub signed: bool,
123    pub referenced_chunks: usize,
124    pub referenced_bytes: u64,
125    /// Stored canonical views (derived entries).
126    pub views: usize,
127    /// Symbolic links found inside input directories and skipped (never followed).
128    pub skipped_links: usize,
129}
130
131/// A block of unique chunks waiting for compression.
132struct Block {
133    chunks: Vec<u32>,
134    plain: Vec<u8>,
135    /// Dictionary chunk indices for delta coding (an earlier version of this content), if any.
136    delta: Option<Vec<u32>>,
137}
138
139/// Bytes of recently stored chunks kept for intra-archive delta dictionaries.
140const DELTA_WINDOW: usize = 32 << 20;
141/// Upper bound of one delta dictionary.
142const DELTA_DICT_MAX: usize = 4 << 20;
143/// Files up to this size are read whole to confirm a delta candidate; larger ones stream without delta.
144const DELTA_WHOLE_FILE_MAX: u64 = 64 << 20;
145/// Chunks stored this deep in a chain of delta dictionaries are not used as dictionary material
146/// (readers accept chains up to 16; keeping them short keeps random access cheap).
147const MAX_WRITER_DELTA_DEPTH: u8 = 6;
148
149enum DictSource {
150    /// Chunks of an entry in reference archive `ri`: (hash, size), stored there, external here.
151    Ref { ri: usize, chunks: Vec<(Hash, u32)> },
152    /// Chunks of an earlier entry of this archive whose bytes are still in the recent window.
153    Local { chunks: Vec<u32> },
154}
155
156struct DeltaCandidate {
157    source: DictSource,
158}
159
160/// Do the new bytes share at least a quarter of their content with `old`, judged on 8 KiB
161/// content-defined pieces (fine enough that a document with edits every few KB still qualifies,
162/// strict enough that two unrelated documents built from the same template do not)?
163fn shares_content(old: &[u8], parts: &[&[u8]]) -> bool {
164    if old.is_empty() {
165        return false;
166    }
167    let fine = ChunkParams::FINE;
168    let prints: HashSet<Hash> = chunk::boundaries(old, &fine).into_iter().map(|(off, len)| chunk::hash(&old[off..off + len])).collect();
169    let (mut shared, mut total) = (0usize, 0usize);
170    for part in parts {
171        for (off, len) in chunk::boundaries(part, &fine) {
172            total += len;
173            if prints.contains(&chunk::hash(&part[off..off + len])) {
174                shared += len;
175            }
176        }
177    }
178    shared > 0 && shared * 4 >= total
179}
180
181/// Chunk indices of an entry in storage order (raw, ZIP members or stream segments).
182fn entry_chunk_indices(e: &Entry) -> Vec<u32> {
183    match (&e.zip, &e.streams) {
184        (Some(z), _) => z.members.iter().flat_map(|m| m.chunks.iter().copied()).collect(),
185        (None, Some(s)) => s.segments.iter().flat_map(|x| x.chunks.iter().copied()).collect(),
186        _ => e.chunks.clone(),
187    }
188}
189
190/// Bucket key for candidate search: extension and the first three bytes of the file name
191/// (candidates need a common name prefix of at least three bytes, so they share the bucket).
192fn name_key(path: &str) -> Option<(String, [u8; 3])> {
193    let name = file_name(path).as_bytes();
194    if name.len() < 3 {
195        return None;
196    }
197    Some((extension(path).to_string(), [name[0], name[1], name[2]]))
198}
199
200type NameIndex = HashMap<(String, [u8; 3]), Vec<usize>>;
201
202fn file_name(path: &str) -> &str {
203    path.rsplit('/').next().unwrap_or(path)
204}
205
206fn extension(path: &str) -> &str {
207    let name = file_name(path);
208    match name.rfind('.') {
209        Some(i) if i > 0 => &name[i + 1..],
210        _ => "",
211    }
212}
213
214/// Length of the common prefix of two file names, in bytes (on char boundaries).
215fn common_prefix(a: &str, b: &str) -> usize {
216    let n = a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count();
217    let mut n = n;
218    while !a.is_char_boundary(n) {
219        n -= 1;
220    }
221    n
222}
223
224struct Packer {
225    opts: PackOptions,
226    out: BufWriter<File>,
227    pos: u64,
228    header: [u8; format::HEADER_LEN],
229    sections: Vec<SectionEntry>,
230    key: Option<ArchiveKey>,
231    // chunk table
232    hashes: Vec<Hash>,
233    sizes: Vec<u32>,
234    external: Vec<u8>,
235    index: HashMap<Hash, u32>,
236    // reference archives ("compression by reference"): hash -> (reference index, chunk index there)
237    ref_lookup: HashMap<Hash, (usize, u32)>,
238    ref_meta: Vec<RefArchive>,
239    refs: Vec<Reader>,
240    /// Per reference archive: exact path -> entry index, and name buckets for similar names.
241    ref_path_index: Vec<HashMap<String, usize>>,
242    ref_name_index: Vec<NameIndex>,
243    /// Name buckets of this archive's entries (delta candidate search).
244    name_index: NameIndex,
245    // delta coding: dictionary of the entry being added, and a window of recent chunk bytes
246    cur_delta: Option<Vec<u32>>,
247    /// Per chunk: depth of the delta chain of the block that stores it (0 = plain block).
248    delta_depth: Vec<u8>,
249    recent: VecDeque<(u32, Arc<Vec<u8>>)>,
250    recent_map: HashMap<u32, Arc<Vec<u8>>>,
251    recent_bytes: usize,
252    referenced_chunks: usize,
253    referenced_bytes: u64,
254    // blocks and blobs
255    current: Option<Block>,
256    ready: Vec<Block>,
257    blob_records: Vec<BlobRecord>,
258    blobs_off: u64,
259    blobs_written: u64,
260    codec_hist: BTreeMap<String, u32>,
261    blocks_sampled: usize,
262    // dictionary warm-up (granular mode)
263    dict: Option<Vec<u8>>,
264    dict_done: bool,
265    warmup: Vec<(u32, Vec<u8>)>,
266    warmup_len: usize,
267    // stats
268    entries: Vec<Entry>,
269    input_bytes: u64,
270    logical_bytes: u64,
271    unique_bytes: u64,
272    chunk_refs: usize,
273    exploded: usize,
274    fallback: usize,
275    views: usize,
276}
277
278impl Packer {
279    fn new(opts: PackOptions, out_path: &Path) -> Result<Self> {
280        opts.chunk.validate()?;
281        if !(1..=5).contains(&opts.effort) {
282            return Err(Error::Invalid(format!("effort {} is outside 1..=5", opts.effort)));
283        }
284        let key = if opts.password.is_some() || !opts.recipients.is_empty() { Some(ArchiveKey::random()?) } else { None };
285        let mut flags = 0u16;
286        if key.is_some() {
287            flags |= format::FLAG_ENCRYPTED;
288        }
289        if opts.sign_seed.is_some() {
290            flags |= format::FLAG_SIGNED;
291        }
292        let header = format::encode_header(&Header { version: format::VERSION, flags });
293        // read+write: the blobs section is re-read from disk to hash it without holding it in memory
294        let file = std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(true).open(out_path)?;
295        let mut out = BufWriter::with_capacity(1 << 20, file);
296        out.write_all(&header)?;
297        // Reference archives: collect every chunk hash they hold.
298        let mut ref_lookup: HashMap<Hash, (usize, u32)> = HashMap::new();
299        let mut ref_meta = Vec::new();
300        let mut refs = Vec::new();
301        let mut ref_path_index = Vec::new();
302        let mut ref_name_index = Vec::new();
303        for (i, rp) in opts.references.iter().enumerate() {
304            let r = Reader::open(rp, opts.password.as_deref()).map_err(|e| Error::Missing(format!("reference archive {}: {e}", rp.display())))?;
305            for (cj, c) in r.index.chunks.iter().enumerate() {
306                if c.x == 0 && c.h.len() == 32 {
307                    let mut h = [0u8; 32];
308                    h.copy_from_slice(&c.h);
309                    ref_lookup.entry(h).or_insert((i, cj as u32));
310                }
311            }
312            ref_meta.push(RefArchive { root: r.manifest.root.clone(), hint: rp.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), chunks: 0, bytes: 0 });
313            let mut paths = HashMap::new();
314            let mut names: NameIndex = HashMap::new();
315            for (ei, e) in r.manifest.entries.iter().enumerate() {
316                if e.derived.is_some() {
317                    continue;
318                }
319                paths.entry(e.path.clone()).or_insert(ei);
320                if let Some(k) = name_key(&e.path) {
321                    names.entry(k).or_default().push(ei);
322                }
323            }
324            ref_path_index.push(paths);
325            ref_name_index.push(names);
326            refs.push(r);
327        }
328        let mut p = Self {
329            opts,
330            out,
331            pos: header.len() as u64,
332            header,
333            sections: Vec::new(),
334            key,
335            hashes: Vec::new(),
336            sizes: Vec::new(),
337            external: Vec::new(),
338            index: HashMap::new(),
339            ref_lookup,
340            ref_meta,
341            refs,
342            ref_path_index,
343            ref_name_index,
344            name_index: HashMap::new(),
345            cur_delta: None,
346            delta_depth: Vec::new(),
347            recent: VecDeque::new(),
348            recent_map: HashMap::new(),
349            recent_bytes: 0,
350            referenced_chunks: 0,
351            referenced_bytes: 0,
352            current: None,
353            ready: Vec::new(),
354            blob_records: Vec::new(),
355            blobs_off: 0,
356            blobs_written: 0,
357            codec_hist: BTreeMap::new(),
358            blocks_sampled: 0,
359            dict: None,
360            dict_done: false,
361            warmup: Vec::new(),
362            warmup_len: 0,
363            entries: Vec::new(),
364            input_bytes: 0,
365            logical_bytes: 0,
366            unique_bytes: 0,
367            chunk_refs: 0,
368            exploded: 0,
369            fallback: 0,
370            views: 0,
371        };
372        if let Some(k) = &p.key {
373            let mut block = crypto::RecipientsBlock::default();
374            if let Some(pw) = &p.opts.password {
375                block.stanzas.push(crypto::wrap_password(k, pw.as_bytes(), &p.opts.kdf)?);
376            }
377            for r in &p.opts.recipients {
378                block.stanzas.push(crypto::hybrid::wrap(k, r)?);
379            }
380            let bytes = format::cbor_encode(&block)?;
381            p.write_section(section::RECIPIENTS, &bytes)?;
382        }
383        // dictionary is trained later; in block mode there is none
384        if p.opts.block_size > 0 || !p.opts.train_dict {
385            p.dict_done = true;
386        }
387        p.blobs_off = p.pos;
388        Ok(p)
389    }
390
391    fn write_section(&mut self, t: u8, bytes: &[u8]) -> Result<()> {
392        let off = self.pos;
393        self.out.write_all(bytes)?;
394        self.pos += bytes.len() as u64;
395        self.sections.push(SectionEntry { t, off, len: bytes.len() as u64, h: ByteBuf::from(chunk::hash(bytes).to_vec()) });
396        Ok(())
397    }
398
399    fn batch_size(&self) -> usize {
400        if self.opts.batch > 0 {
401            self.opts.batch
402        } else {
403            rayon::current_num_threads().max(1)
404        }
405    }
406
407    /// Blocks are compressed in batches; a batch is bounded both by count (threads) and by bytes
408    /// so that memory stays predictable whatever the block size.
409    const MAX_INFLIGHT_BYTES: usize = 128 << 20;
410
411    fn batch_ready(&self) -> bool {
412        self.ready.len() >= self.batch_size() || self.ready.iter().map(|b| b.plain.len()).sum::<usize>() >= Self::MAX_INFLIGHT_BYTES
413    }
414
415    /// Register a chunk (dedup) and return its index; new chunks are queued for compression.
416    fn ingest_chunk(&mut self, data: &[u8]) -> Result<u32> {
417        let h = chunk::hash(data);
418        self.chunk_refs += 1;
419        self.logical_bytes += data.len() as u64;
420        if let Some(&i) = self.index.get(&h) {
421            return Ok(i);
422        }
423        let idx = self.hashes.len() as u32;
424        self.hashes.push(h);
425        self.sizes.push(data.len() as u32);
426        self.delta_depth.push(0);
427        self.index.insert(h, idx);
428        // Present in a reference archive: record it as external and store nothing.
429        if let Some(&(ri, _)) = self.ref_lookup.get(&h) {
430            self.external.push(1);
431            self.referenced_chunks += 1;
432            self.referenced_bytes += data.len() as u64;
433            self.ref_meta[ri].chunks += 1;
434            self.ref_meta[ri].bytes += data.len() as u64;
435            return Ok(idx);
436        }
437        self.external.push(0);
438        self.unique_bytes += data.len() as u64;
439        self.remember(idx, data);
440
441        if self.opts.block_size == 0 {
442            if !self.dict_done {
443                self.warmup.push((idx, data.to_vec()));
444                self.warmup_len += data.len();
445                if self.warmup_len >= self.opts.dict_warmup {
446                    self.finish_warmup()?;
447                }
448            } else {
449                self.ready.push(Block { chunks: vec![idx], plain: data.to_vec(), delta: None });
450                if self.batch_ready() {
451                    self.flush_ready()?;
452                }
453            }
454        } else {
455            // a block never mixes chunks with different delta dictionaries
456            let full = match &self.current {
457                // a versioned entry starts a new block when the current one carries a different
458                // dictionary or holds the dictionary chunks themselves (a blob can never be its own dictionary)
459                Some(b) => {
460                    (b.plain.len() + data.len() > self.opts.block_size as usize && !b.plain.is_empty())
461                        || self.cur_delta.as_ref().is_some_and(|d| (b.delta.is_some() && b.delta.as_ref() != Some(d)) || d.iter().any(|ci| b.chunks.contains(ci)))
462                }
463                None => false,
464            };
465            if full {
466                let b = self.current.take().unwrap();
467                self.ready.push(b);
468                if self.batch_ready() {
469                    self.flush_ready()?;
470                }
471            }
472            match &mut self.current {
473                Some(b) => {
474                    b.chunks.push(idx);
475                    b.plain.extend_from_slice(data);
476                }
477                None => self.current = Some(Block { chunks: vec![idx], plain: data.to_vec(), delta: self.cur_delta.clone() }),
478            }
479            // a block adopts the dictionary of the first versioned entry it receives; every chunk
480            // stored in such a block sits one level deeper in the chain than the dictionary chunks
481            if let Some(b) = &mut self.current {
482                let adopted = b.delta.is_none() && self.cur_delta.is_some();
483                if adopted {
484                    b.delta = self.cur_delta.clone();
485                }
486                if let Some(dict) = &b.delta {
487                    let depth = dict.iter().map(|&ci| self.delta_depth[ci as usize]).max().unwrap_or(0).saturating_add(1);
488                    if adopted {
489                        for &ci in &b.chunks {
490                            self.delta_depth[ci as usize] = self.delta_depth[ci as usize].max(depth);
491                        }
492                    } else {
493                        self.delta_depth[idx as usize] = self.delta_depth[idx as usize].max(depth);
494                    }
495                }
496            }
497        }
498        Ok(idx)
499    }
500
501    /// Append an entry and index its name for delta candidate search (views are never candidates).
502    fn register_entry(&mut self, entry: Entry) {
503        if entry.derived.is_none() {
504            if let Some(k) = name_key(&entry.path) {
505                self.name_index.entry(k).or_default().push(self.entries.len());
506            }
507        }
508        self.entries.push(entry);
509    }
510
511    /// Keep the bytes of recently stored chunks (bounded window) for intra-archive delta dictionaries.
512    fn remember(&mut self, idx: u32, data: &[u8]) {
513        if !self.opts.delta || self.opts.block_size == 0 {
514            return;
515        }
516        let arc = Arc::new(data.to_vec());
517        self.recent_bytes += data.len();
518        self.recent.push_back((idx, arc.clone()));
519        self.recent_map.insert(idx, arc);
520        while self.recent_bytes > DELTA_WINDOW {
521            if let Some((old, bytes)) = self.recent.pop_front() {
522                self.recent_bytes -= bytes.len();
523                self.recent_map.remove(&old);
524            } else {
525                break;
526            }
527        }
528    }
529
530    /// Register a chunk of a reference archive as external in this archive's chunk table.
531    fn intern_external(&mut self, h: Hash, size: u32, ri: usize) -> u32 {
532        if let Some(&i) = self.index.get(&h) {
533            return i;
534        }
535        let idx = self.hashes.len() as u32;
536        self.hashes.push(h);
537        self.sizes.push(size);
538        self.delta_depth.push(0);
539        self.external.push(1);
540        self.index.insert(h, idx);
541        self.referenced_chunks += 1;
542        self.referenced_bytes += size as u64;
543        self.ref_meta[ri].chunks += 1;
544        self.ref_meta[ri].bytes += size as u64;
545        idx
546    }
547
548    /// Candidate earlier versions of `path`, best first: the same path in a reference archive,
549    /// then similarly named entries (same extension, common name prefix of at least half the
550    /// name) of the reference archives and of this archive; at most three are tried.
551    fn delta_candidates(&self, path: &str) -> Vec<DeltaCandidate> {
552        let name = file_name(path);
553        let ext = extension(path);
554        let similar = |other: &str| -> Option<usize> {
555            if other == path || extension(other) != ext {
556                return None;
557            }
558            let other_name = file_name(other);
559            let lcp = common_prefix(name, other_name);
560            (lcp >= 3 && lcp * 2 >= name.len().min(other_name.len())).then_some(lcp)
561        };
562        let mut ranked: Vec<(usize, usize, DeltaCandidate)> = Vec::new(); // (score, sequence, candidate)
563        let key = name_key(path);
564        for (ri, r) in self.refs.iter().enumerate() {
565            let exact = self.ref_path_index[ri].get(path).copied();
566            let bucket = key.as_ref().and_then(|k| self.ref_name_index[ri].get(k)).map(|v| v.as_slice()).unwrap_or(&[]);
567            for &ei in exact.iter().chain(bucket.iter()) {
568                let e = &r.manifest.entries[ei];
569                let score = if e.path == path {
570                    usize::MAX
571                } else {
572                    match similar(&e.path) {
573                        Some(l) => l,
574                        None => continue,
575                    }
576                };
577                let mut chunks = Vec::new();
578                for cj in entry_chunk_indices(e) {
579                    let c = &r.index.chunks[cj as usize];
580                    if c.x != 0 || c.h.len() != 32 {
581                        continue;
582                    }
583                    let mut h = [0u8; 32];
584                    h.copy_from_slice(&c.h);
585                    chunks.push((h, c.s));
586                }
587                if !chunks.is_empty() {
588                    ranked.push((score, ranked.len(), DeltaCandidate { source: DictSource::Ref { ri, chunks } }));
589                }
590            }
591        }
592        let bucket = key.as_ref().and_then(|k| self.name_index.get(k)).map(|v| v.as_slice()).unwrap_or(&[]);
593        for &ei in bucket {
594            let e = &self.entries[ei];
595            let Some(score) = similar(&e.path) else { continue };
596            let mut chunks = Vec::new();
597            for ci in entry_chunk_indices(e) {
598                // dictionary material: not too deep in a delta chain, bytes at hand (recent window or a reference)
599                if self.delta_depth[ci as usize] < MAX_WRITER_DELTA_DEPTH && (self.recent_map.contains_key(&ci) || self.external[ci as usize] != 0) {
600                    chunks.push(ci);
601                }
602            }
603            if !chunks.is_empty() {
604                ranked.push((score, ranked.len(), DeltaCandidate { source: DictSource::Local { chunks } }));
605            }
606        }
607        // best score first; among equals the most recently added entry wins
608        ranked.sort_by_key(|x| std::cmp::Reverse((x.0, x.1)));
609        ranked.truncate(3);
610        ranked.into_iter().map(|(_, _, c)| c).collect()
611    }
612
613    /// Bytes of one dictionary chunk: the recent window for stored chunks, a reference archive for external ones.
614    fn dict_chunk_bytes(&mut self, ci: u32) -> Option<Vec<u8>> {
615        if self.external[ci as usize] != 0 {
616            let (ri, cj) = *self.ref_lookup.get(&self.hashes[ci as usize])?;
617            self.refs[ri].chunk(cj).ok()
618        } else {
619            self.recent_map.get(&ci).map(|b| b.to_vec())
620        }
621    }
622
623    /// Confirm a candidate against the bytes about to be stored (at least one shared chunk), then
624    /// build the dictionary chunk list; reference chunks are registered as external only now.
625    fn confirm_delta(&mut self, cand: DeltaCandidate, parts: &[&[u8]]) -> Option<Vec<u32>> {
626        // 1. the candidate's bytes (bounded), which also become the dictionary
627        let mut dict = Vec::new();
628        let mut out = Vec::new();
629        match cand.source {
630            DictSource::Ref { ri, chunks } => {
631                for (h, size) in chunks {
632                    if dict.len() + size as usize > DELTA_DICT_MAX {
633                        break;
634                    }
635                    let (_, cj) = *self.ref_lookup.get(&h)?;
636                    let bytes = self.refs[ri].chunk(cj).ok()?;
637                    dict.extend_from_slice(&bytes);
638                    out.push((h, size));
639                }
640                // 2. fine-grained fingerprints: at least a quarter of the new bytes must be shared
641                if !shares_content(&dict, parts) {
642                    return None;
643                }
644                let idx: Vec<u32> = out.into_iter().map(|(h, size)| self.intern_external(h, size, ri)).collect();
645                (!idx.is_empty()).then_some(idx)
646            }
647            DictSource::Local { chunks } => {
648                let mut idx = Vec::new();
649                for ci in chunks {
650                    let bytes = self.dict_chunk_bytes(ci)?;
651                    if dict.len() + bytes.len() > DELTA_DICT_MAX {
652                        break;
653                    }
654                    dict.extend_from_slice(&bytes);
655                    idx.push(ci);
656                }
657                if !shares_content(&dict, parts) {
658                    return None;
659                }
660                (!idx.is_empty()).then_some(idx)
661            }
662        }
663    }
664
665    /// When the whole dictionary already sits in the current block and the incoming entry fits in
666    /// it too, the codec's own window covers the earlier version: no delta, no split.
667    fn drop_delta_if_base_in_block(&mut self, incoming: usize) {
668        if let (Some(d), Some(b)) = (&self.cur_delta, &self.current) {
669            // (at least half of the incoming entry must still fit next to its base for the window to matter)
670            if b.delta.is_some() || b.plain.len() + incoming / 2 > self.opts.block_size as usize {
671                return;
672            }
673            let (mut inside, mut outside) = (0usize, 0usize);
674            for &ci in d {
675                let size = self.sizes[ci as usize] as usize;
676                if b.chunks.contains(&ci) {
677                    inside += size;
678                } else {
679                    outside += size;
680                }
681            }
682            if outside * 4 < inside + outside {
683                self.cur_delta = None;
684            }
685        }
686    }
687
688    /// Bytes of a delta dictionary (None when any chunk is unavailable, then no delta is tried).
689    fn delta_dictionary(&mut self, chunks: &[u32]) -> Option<Vec<u8>> {
690        let mut dict = Vec::new();
691        for &ci in chunks {
692            dict.extend_from_slice(&self.dict_chunk_bytes(ci)?);
693        }
694        Some(dict)
695    }
696
697    /// Train the dictionary on the warm-up chunks, then queue them.
698    fn finish_warmup(&mut self) -> Result<()> {
699        if self.dict_done {
700            return Ok(());
701        }
702        self.dict_done = true;
703        let samples: Vec<&[u8]> = self.warmup.iter().map(|(_, d)| d.as_slice()).collect();
704        self.dict = codec::train_dict(&samples, self.opts.dict_size);
705        let warm = std::mem::take(&mut self.warmup);
706        for (idx, data) in warm {
707            self.ready.push(Block { chunks: vec![idx], plain: data, delta: None });
708            if self.batch_ready() {
709                self.flush_ready()?;
710            }
711        }
712        Ok(())
713    }
714
715    /// Compress the ready blocks in parallel and write them in order.
716    fn flush_ready(&mut self) -> Result<()> {
717        if self.ready.is_empty() {
718            return Ok(());
719        }
720        let mut blocks = std::mem::take(&mut self.ready);
721        let copts = CodecOptions { level: self.opts.level, long_window_log: 27, dict: self.dict.clone(), choice: self.opts.codec, effort: self.opts.effort };
722        // delta dictionaries are gathered up front (reference archives and the recent window)
723        let mut dicts: Vec<Option<Vec<u8>>> = Vec::with_capacity(blocks.len());
724        for b in blocks.iter_mut() {
725            let d = match &b.delta {
726                Some(chunks) => self.delta_dictionary(chunks),
727                None => None,
728            };
729            if d.is_none() {
730                b.delta = None;
731            }
732            dicts.push(d);
733        }
734        let level = self.opts.level;
735        let compressed: Vec<codec::Encoded> = blocks
736            .par_iter()
737            .zip(dicts.par_iter())
738            .map(|(b, d)| {
739                let mut e = codec::compress_best(&b.plain, &copts)?;
740                if let Some(dict) = d {
741                    let z = codec::zstd_compress_with_prefix(&b.plain, level, dict)?;
742                    if z.len() < e.bytes.len() {
743                        e = codec::Encoded { codec: codec::ZSTD_DELTA, bytes: z, params: Vec::new(), filter: codec::FILTER_NONE };
744                    }
745                }
746                Ok(e)
747            })
748            .collect::<Result<Vec<_>>>()?;
749        for (b, e) in blocks.into_iter().zip(compressed) {
750            let c = e.codec;
751            *self.codec_hist.entry(codec::name(c).to_string()).or_insert(0) += 1;
752            if codec::samples_block(b.plain.len(), &copts) {
753                self.blocks_sampled += 1;
754            }
755            let blob_index = self.blob_records.len() as u64;
756            let aad = blob_aad(c, b.plain.len() as u32, b.chunks[0], b.chunks.len() as u32);
757            let stored = match &self.key {
758                Some(k) => crypto::seal(k, crypto::LABEL_BLOB, blob_index, &aad, &e.bytes)?,
759                None => e.bytes,
760            };
761            match codec::filter_id(e.filter) {
762                codec::FILTER_NONE => {}
763                codec::FILTER_X86 => *self.codec_hist.entry("filter:x86".to_string()).or_insert(0) += 1,
764                codec::FILTER_ARM64 => *self.codec_hist.entry("filter:arm64".to_string()).or_insert(0) += 1,
765                _ => *self.codec_hist.entry("filter:other".to_string()).or_insert(0) += 1,
766            }
767            let d = if c == codec::ZSTD_DELTA { b.delta.unwrap_or_default() } else { Vec::new() };
768            self.blob_records.push(BlobRecord { off: self.blobs_written, clen: stored.len() as u32, ulen: b.plain.len() as u32, codec: c, n: b.chunks, p: e.params, f: e.filter, d });
769            self.out.write_all(&stored)?;
770            self.blobs_written += stored.len() as u64;
771            self.pos += stored.len() as u64;
772        }
773        Ok(())
774    }
775
776    fn entry_skeleton(&self, path: String, size: u64, hash: Hash, fs_path: &Path) -> Entry {
777        let mtime = if self.opts.keep_mtime {
778            std::fs::metadata(fs_path).ok().and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs() as i64)
779        } else {
780            None
781        };
782        Entry { path, size, h: ByteBuf::from(hash.to_vec()), mode: "raw".into(), chunks: Vec::new(), mtime, zip: None, streams: None, note: None, derived: None }
783    }
784
785    fn add_file(&mut self, fs_path: &Path, arcname: &str) -> Result<()> {
786        let path = paths::normalize(arcname)?;
787        let size = std::fs::metadata(fs_path)?.len();
788        if size > self.opts.limits.max_entry {
789            return Err(Error::Limit(format!("{path}: entry larger than max_entry")));
790        }
791        self.input_bytes += size;
792        if self.input_bytes > self.opts.limits.max_total {
793            return Err(Error::Limit("total input larger than max_total".into()));
794        }
795
796        // Delta coding: a candidate earlier version is confirmed on the real bytes before use.
797        let candidates = if self.opts.delta && self.opts.block_size > 0 { self.delta_candidates(&path) } else { Vec::new() };
798        self.cur_delta = None;
799
800        // Containers need the whole file (preflate), stored views and delta candidates too; everything else streams.
801        let is_container = self.opts.container_aware && (container::is_zip_like(&path) || container::is_stream_container(&path)) && size > 64;
802        let wants_view = self.opts.canonical && canonical::derivable(&path).is_some();
803        if is_container || wants_view || (!candidates.is_empty() && size <= DELTA_WHOLE_FILE_MAX) {
804            let data = std::fs::read(fs_path)?;
805            if is_container {
806                self.add_container(&path, &data, size, fs_path, candidates)?;
807            } else {
808                for c in candidates {
809                    if let Some(d) = self.confirm_delta(c, &[&data]) {
810                        self.cur_delta = Some(d);
811                        break;
812                    }
813                }
814                self.drop_delta_if_base_in_block(data.len());
815                let mut entry = self.entry_skeleton(path.clone(), size, chunk::hash(&data), fs_path);
816                entry.chunks = self.chunk_buffer(&data)?;
817                self.register_entry(entry);
818                self.cur_delta = None;
819            }
820            if wants_view {
821                self.cur_delta = None;
822                self.add_view(&path, &data)?;
823            }
824            return Ok(());
825        }
826
827        // Streaming path: chunk the file as it is read, hash the whole file on the fly.
828        let file = File::open(fs_path)?;
829        let reader = BufReader::with_capacity(1 << 20, file);
830        let p = self.opts.chunk;
831        let mut hasher = blake3::Hasher::new();
832        let mut refs = Vec::new();
833        let mut total = 0u64;
834        if size > 0 {
835            let cdc = StreamCDC::with_level(reader, p.min, p.avg, p.max, Normalization::Level2);
836            for item in cdc {
837                let c = item.map_err(|e| Error::Io(std::io::Error::other(format!("chunking {}: {e}", fs_path.display()))))?;
838                hasher.update(&c.data);
839                total += c.data.len() as u64;
840                refs.push(self.ingest_chunk(&c.data)?);
841            }
842        }
843        if total != size {
844            return Err(Error::Io(std::io::Error::other(format!("{}: size changed while reading", fs_path.display()))));
845        }
846        let mut entry = self.entry_skeleton(path, size, *hasher.finalize().as_bytes(), fs_path);
847        entry.chunks = refs;
848        self.register_entry(entry);
849        Ok(())
850    }
851
852    /// A ZIP/OPC, PDF or JPEG file: explode it into invertible parts when the rebuild is bit-exact,
853    /// otherwise store it raw with a note.
854    fn add_container(&mut self, path: &str, data: &[u8], size: u64, fs_path: &Path, mut candidates: Vec<DeltaCandidate>) -> Result<()> {
855        let mut entry = self.entry_skeleton(path.to_string(), size, chunk::hash(data), fs_path);
856        if container::is_zip_like(path) {
857            if let Some((mut recipe, parts)) = container::try_bitexact(data) {
858                let views: Vec<&[u8]> = parts.iter().map(|p| p.as_slice()).collect();
859                for c in std::mem::take(&mut candidates) {
860                    if let Some(d) = self.confirm_delta(c, &views) {
861                        self.cur_delta = Some(d);
862                        break;
863                    }
864                }
865                self.drop_delta_if_base_in_block(views.iter().map(|v| v.len()).sum());
866                for (m, part) in recipe.members.iter_mut().zip(parts.iter()) {
867                    m.chunks = self.chunk_buffer(part)?;
868                }
869                self.cur_delta = None;
870                entry.mode = "zip".into();
871                entry.zip = Some(recipe);
872                self.exploded += 1;
873                self.register_entry(entry);
874                return Ok(());
875            }
876            entry.note = Some("container could not be rebuilt bit-exact; stored raw".into());
877        } else if let Some((mut recipe, parts)) = container::try_bitexact_streams(data, self.opts.jpeg_recompression) {
878            let views: Vec<&[u8]> = parts.iter().map(|p| p.as_slice()).collect();
879            for c in std::mem::take(&mut candidates) {
880                if let Some(d) = self.confirm_delta(c, &views) {
881                    self.cur_delta = Some(d);
882                    break;
883                }
884            }
885            self.drop_delta_if_base_in_block(views.iter().map(|v| v.len()).sum());
886            for (s, part) in recipe.segments.iter_mut().zip(parts.iter()) {
887                s.chunks = self.chunk_buffer(part)?;
888            }
889            self.cur_delta = None;
890            entry.mode = "streams".into();
891            entry.streams = Some(recipe);
892            self.exploded += 1;
893            self.register_entry(entry);
894            return Ok(());
895        } else {
896            entry.note = Some("no invertible zlib streams found; stored raw".into());
897        }
898        self.fallback += 1;
899        for c in std::mem::take(&mut candidates) {
900            if let Some(d) = self.confirm_delta(c, &[data]) {
901                self.cur_delta = Some(d);
902                break;
903            }
904        }
905        self.drop_delta_if_base_in_block(data.len());
906        entry.chunks = self.chunk_buffer(data)?;
907        self.cur_delta = None;
908        self.register_entry(entry);
909        Ok(())
910    }
911
912    /// Generate and store the canonical view of a DOCX/PDF entry as a derived entry
913    /// `.tsaur/views/<path>.<md|txt>`. A view that cannot be generated is recorded as a note on
914    /// the source entry; the original is stored in every case.
915    fn add_view(&mut self, source: &str, data: &[u8]) -> Result<()> {
916        let Some(kind) = canonical::derivable(source) else { return Ok(()) };
917        let text = match canonical::canonical_view(source, data) {
918            Ok(Some(t)) => t,
919            Ok(None) => return Ok(()),
920            Err(e) => {
921                if let Some(entry) = self.entries.iter_mut().rev().find(|e| e.path == source) {
922                    entry.note = Some(format!("canonical view not generated: {e}"));
923                }
924                return Ok(());
925            }
926        };
927        let vpath = paths::normalize(&canonical::view_path(source, kind))?;
928        let bytes = text.into_bytes();
929        let derived = Derived { from: source.to_string(), view: kind.to_string(), generator: crate::WRITER.to_string(), tokens_est: (bytes.len() as f64 / 3.5).ceil() as u64 };
930        let mut entry = Entry {
931            path: vpath,
932            size: bytes.len() as u64,
933            h: ByteBuf::from(chunk::hash(&bytes).to_vec()),
934            mode: "raw".into(),
935            chunks: Vec::new(),
936            mtime: None,
937            zip: None,
938            streams: None,
939            note: None,
940            derived: Some(derived),
941        };
942        entry.chunks = self.chunk_buffer(&bytes)?;
943        self.register_entry(entry);
944        self.views += 1;
945        Ok(())
946    }
947
948    fn chunk_buffer(&mut self, data: &[u8]) -> Result<Vec<u32>> {
949        let mut refs = Vec::new();
950        for (off, len) in chunk::boundaries(data, &self.opts.chunk) {
951            refs.push(self.ingest_chunk(&data[off..off + len])?);
952        }
953        Ok(refs)
954    }
955
956    fn finish(mut self) -> Result<PackReport> {
957        // flush everything that is still queued
958        self.finish_warmup()?;
959        if let Some(b) = self.current.take() {
960            self.ready.push(b);
961        }
962        self.flush_ready()?;
963        let opts = self.opts.clone();
964
965        // blobs section (contiguous, already written)
966        let blobs_len = self.blobs_written;
967        let blobs_off = self.blobs_off;
968        let blobs_hash = self.hash_written_range(blobs_off, blobs_len)?;
969        self.sections.push(SectionEntry { t: section::BLOBS, off: blobs_off, len: blobs_len, h: ByteBuf::from(blobs_hash.to_vec()) });
970
971        let mut dict_bytes = 0usize;
972        if let Some(d) = self.dict.clone() {
973            dict_bytes = d.len();
974            let bytes = match &self.key {
975                Some(k) => crypto::seal(k, crypto::LABEL_DICT, 0, b"dict", &d)?,
976                None => d,
977            };
978            self.write_section(section::DICTIONARY, &bytes)?;
979        }
980
981        let index = ChunkIndex {
982            chunks: self.hashes.iter().zip(self.sizes.iter()).zip(self.external.iter()).map(|((h, s), x)| ChunkRef { h: ByteBuf::from(h.to_vec()), s: *s, x: *x }).collect(),
983            blobs: std::mem::take(&mut self.blob_records),
984        };
985        let index_bytes = seal_section(&self.key, crypto::LABEL_INDEX, b"index", &zstd_wrap(&format::cbor_encode(&index)?)?)?;
986        self.write_section(section::CHUNK_INDEX, &index_bytes)?;
987
988        let root = chunk::merkle_root(&self.hashes);
989        let entries = std::mem::take(&mut self.entries);
990        let n_entries = entries.len();
991        let manifest = Manifest {
992            tsaur: 1,
993            profile: opts.profile.clone(),
994            fidelity: if opts.canonical { "hybrid".into() } else { "bit-exact".into() },
995            chunking: opts.chunk,
996            solid_block: opts.block_size,
997            limits: opts.limits,
998            root: ByteBuf::from(root.to_vec()),
999            entries,
1000            created: if opts.timestamp { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs()) } else { None },
1001            generator: crate::WRITER.to_string(),
1002            refs: std::mem::take(&mut self.ref_meta),
1003        };
1004        let manifest_bytes = seal_section(&self.key, crypto::LABEL_MANIFEST, b"manifest", &zstd_wrap(&format::cbor_encode(&manifest)?)?)?;
1005        self.write_section(section::MANIFEST, &manifest_bytes)?;
1006
1007        if let Some(seed) = &opts.sign_seed {
1008            let msg = format::signing_message(&self.header, &self.sections);
1009            let block = SignatureBlock { alg: "ed25519".into(), key: ByteBuf::from(crypto::sig::public_key(seed).to_vec()), sig: ByteBuf::from(crypto::sig::sign(seed, &msg).to_vec()) };
1010            let bytes = format::cbor_encode(&block)?;
1011            self.write_section(section::SIGNATURES, &bytes)?;
1012        }
1013
1014        let table = format::cbor_encode(&self.sections)?;
1015        let table_off = self.pos;
1016        self.out.write_all(&table)?;
1017        self.out.write_all(&format::encode_trailer(table_off, table.len() as u64, format::crc32(&table)))?;
1018        self.out.flush()?;
1019        let archive_bytes = table_off + table.len() as u64 + format::TRAILER_LEN as u64;
1020        let blobs = index.blobs.len();
1021
1022        Ok(PackReport {
1023            skipped_links: 0,
1024            archive_bytes,
1025            input_bytes: self.input_bytes,
1026            logical_bytes: self.logical_bytes,
1027            unique_bytes: self.unique_bytes,
1028            chunks_total: self.chunk_refs,
1029            chunks_unique: self.hashes.len(),
1030            blobs,
1031            dict_bytes,
1032            codec_hist: self.codec_hist.clone(),
1033            blocks_sampled: self.blocks_sampled,
1034            entries: n_entries,
1035            containers_exploded: self.exploded,
1036            containers_fallback: self.fallback,
1037            root: hex::encode(root),
1038            encrypted: self.key.is_some(),
1039            signed: opts.sign_seed.is_some(),
1040            referenced_chunks: self.referenced_chunks,
1041            referenced_bytes: self.referenced_bytes,
1042            views: self.views,
1043        })
1044    }
1045
1046    /// Hash a range already written to the output (re-read from disk, streaming).
1047    fn hash_written_range(&mut self, off: u64, len: u64) -> Result<Hash> {
1048        use std::io::{Read, Seek, SeekFrom};
1049        self.out.flush()?;
1050        let mut f = self.out.get_ref().try_clone()?;
1051        f.seek(SeekFrom::Start(off))?;
1052        let mut hasher = blake3::Hasher::new();
1053        let mut remaining = len;
1054        let mut buf = vec![0u8; 1 << 20];
1055        while remaining > 0 {
1056            let want = remaining.min(buf.len() as u64) as usize;
1057            let n = f.read(&mut buf[..want])?;
1058            if n == 0 {
1059                return Err(Error::Io(std::io::Error::other("short read while hashing output")));
1060            }
1061            hasher.update(&buf[..n]);
1062            remaining -= n as u64;
1063        }
1064        Ok(*hasher.finalize().as_bytes())
1065    }
1066}
1067
1068pub(crate) fn blob_aad(codec: u8, ulen: u32, first: u32, count: u32) -> [u8; 13] {
1069    let mut a = [0u8; 13];
1070    a[0] = codec;
1071    a[1..5].copy_from_slice(&ulen.to_le_bytes());
1072    a[5..9].copy_from_slice(&first.to_le_bytes());
1073    a[9..13].copy_from_slice(&count.to_le_bytes());
1074    a
1075}
1076
1077/// Sections that hold structured data are zstd-compressed (level 19) before sealing.
1078pub(crate) fn zstd_wrap(bytes: &[u8]) -> Result<Vec<u8>> {
1079    let mut c = zstd::bulk::Compressor::new(19)?;
1080    let mut out = (bytes.len() as u32).to_le_bytes().to_vec();
1081    out.extend_from_slice(&c.compress(bytes)?);
1082    Ok(out)
1083}
1084
1085pub(crate) fn zstd_unwrap(bytes: &[u8], max: usize) -> Result<Vec<u8>> {
1086    if bytes.len() < 4 {
1087        return Err(Error::Corrupt("section too short".into()));
1088    }
1089    let ulen = u32::from_le_bytes(bytes[0..4].try_into().unwrap()) as usize;
1090    if ulen > max {
1091        return Err(Error::Limit(format!("section declares {ulen} bytes, limit {max}")));
1092    }
1093    let mut d = zstd::bulk::Decompressor::new()?;
1094    let out = d.decompress(&bytes[4..], ulen)?;
1095    if out.len() != ulen {
1096        return Err(Error::Corrupt("section length mismatch".into()));
1097    }
1098    Ok(out)
1099}
1100
1101fn seal_section(key: &Option<ArchiveKey>, label: u8, aad: &[u8], bytes: &[u8]) -> Result<Vec<u8>> {
1102    match key {
1103        Some(k) => crypto::seal(k, label, 0, aad, bytes),
1104        None => Ok(bytes.to_vec()),
1105    }
1106}
1107
1108/// (filesystem path, archive name) pairs, sorted by archive name.
1109pub type Inputs = Vec<(PathBuf, String)>;
1110
1111/// Collect (filesystem path, archive name) pairs from files and directories, sorted by name.
1112pub fn collect_inputs(inputs: &[PathBuf]) -> Result<Inputs> {
1113    Ok(collect_inputs_noting(inputs)?.0)
1114}
1115
1116/// Like `collect_inputs`, also returning the symbolic links that were skipped. Links found
1117/// inside a directory are never followed: their targets may lie outside the inputs and a
1118/// directory link can loop. An input named explicitly on the command line is followed, because
1119/// that is what the caller asked for. Links are never recreated on extraction (`docs/V1-CONTRACT.md`).
1120pub fn collect_inputs_noting(inputs: &[PathBuf]) -> Result<(Inputs, Vec<PathBuf>)> {
1121    let mut out = Vec::new();
1122    let mut skipped = Vec::new();
1123    for input in inputs {
1124        if input.is_dir() {
1125            let mut stack = vec![input.clone()];
1126            while let Some(dir) = stack.pop() {
1127                let mut children: Vec<_> = std::fs::read_dir(&dir)?.collect::<std::io::Result<Vec<_>>>()?;
1128                children.sort_by_key(|e| e.file_name());
1129                for e in children {
1130                    let p = e.path();
1131                    let kind = e.file_type()?;
1132                    if kind.is_symlink() {
1133                        skipped.push(p);
1134                    } else if kind.is_dir() {
1135                        stack.push(p);
1136                    } else if kind.is_file() {
1137                        let rel = p.strip_prefix(input).map_err(|_| Error::Invalid("path prefix".into()))?;
1138                        out.push((p.clone(), rel.to_string_lossy().replace('\\', "/")));
1139                    }
1140                }
1141            }
1142        } else if input.is_file() {
1143            let name = input.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
1144            out.push((input.clone(), name));
1145        } else {
1146            return Err(Error::Missing(format!("input not found: {}", input.display())));
1147        }
1148    }
1149    out.sort_by(|a, b| a.1.cmp(&b.1));
1150    out.dedup_by(|a, b| a.1 == b.1);
1151    Ok((out, skipped))
1152}
1153
1154/// Pack `inputs` (files or directories) into `out_path`.
1155pub fn pack(inputs: &[PathBuf], out_path: &Path, opts: PackOptions) -> Result<PackReport> {
1156    let (files, skipped) = collect_inputs_noting(inputs)?;
1157    if files.is_empty() {
1158        return Err(Error::Missing("nothing to pack".into()));
1159    }
1160    let result = (|| {
1161        let mut p = Packer::new(opts, out_path)?;
1162        for (fs_path, arcname) in &files {
1163            p.add_file(fs_path, arcname)?;
1164        }
1165        p.finish()
1166    })();
1167    if result.is_err() {
1168        let _ = std::fs::remove_file(out_path);
1169    }
1170    result.map(|mut r| {
1171        r.skipped_links = skipped.len();
1172        r
1173    })
1174}