Skip to main content

tsaur_core/
read.rs

1//! Reading: open → verify framing and every section hash → (unwrap key) → decode index and
2//! manifest → serve chunks, ranges, entries; verify; extract with safe paths.
3
4use crate::chunk;
5use crate::codec;
6use crate::container;
7use crate::crypto::{self, ArchiveKey, Credentials, RecipientsBlock};
8use crate::error::{Error, Result};
9use crate::format::{self, section, Header, SectionEntry, SignatureBlock};
10use crate::manifest::{ChunkIndex, Entry, Manifest};
11use crate::pack::{blob_aad, zstd_unwrap};
12use crate::paths;
13use serde::Serialize;
14use std::collections::HashMap;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18/// Hard ceilings applied by every reader regardless of what the manifest declares.
19pub const READER_MAX_TOTAL: u64 = 64 << 30;
20pub const READER_MAX_SECTION: usize = 1 << 30;
21/// Entries a manifest may declare (`docs/V1-CONTRACT.md` §4).
22pub const READER_MAX_ENTRIES: usize = 10_000_000;
23/// Plaintext cache budget for decoded blobs.
24pub const CACHE_BYTES: usize = 64 << 20;
25
26enum Backing {
27    Mem(Vec<u8>),
28    Map(memmap2::Mmap),
29}
30
31impl std::ops::Deref for Backing {
32    type Target = [u8];
33    fn deref(&self) -> &[u8] {
34        match self {
35            Backing::Mem(v) => v,
36            Backing::Map(m) => m,
37        }
38    }
39}
40
41pub struct Reader {
42    data: Backing,
43    pub header: Header,
44    pub sections: Vec<SectionEntry>,
45    pub manifest: Manifest,
46    pub index: ChunkIndex,
47    dict: Option<Vec<u8>>,
48    key: Option<ArchiveKey>,
49    blobs_off: u64,
50    blob_of_chunk: Vec<u32>,
51    cache: HashMap<u32, Arc<Vec<u8>>>,
52    signature: Option<SignatureBlock>,
53    /// Reference archives that resolve external chunks (see `Manifest::refs`).
54    refs: Vec<Reader>,
55    ref_lookup: HashMap<[u8; 32], (usize, u32)>,
56}
57
58#[derive(Clone, Debug, Serialize)]
59pub struct VerifyReport {
60    pub sections_ok: bool,
61    pub entries_total: usize,
62    pub entries_ok: usize,
63    pub entries_bad: Vec<String>,
64    pub blobs: usize,
65    pub signature: Option<String>,
66    pub signature_valid: Option<bool>,
67}
68
69/// Longest chain of delta dictionaries a reader will follow.
70pub const MAX_DELTA_DEPTH: u8 = 16;
71
72/// Depth of the delta chain below blob `bi` (0 for a plain blob); rejects cycles, self-references
73/// and chains deeper than `MAX_DELTA_DEPTH`. Recursion is bounded by that depth.
74fn delta_depth(bi: usize, blobs: &[crate::manifest::BlobRecord], blob_of_chunk: &[u32], memo: &mut [u8], visiting: &mut [bool]) -> Result<u8> {
75    if blobs[bi].codec != codec::ZSTD_DELTA {
76        return Ok(0);
77    }
78    if memo[bi] != 0 {
79        return Ok(memo[bi]);
80    }
81    if visiting[bi] {
82        return Err(Error::Corrupt(format!("delta blob {bi}: dictionary cycle")));
83    }
84    visiting[bi] = true;
85    let mut depth = 1u8;
86    for &ci in &blobs[bi].d {
87        let owner = blob_of_chunk[ci as usize];
88        if owner == u32::MAX {
89            continue; // external chunk: resolved by a reference archive
90        }
91        if owner as usize == bi {
92            return Err(Error::Corrupt(format!("delta blob {bi}: dictionary made of its own chunks")));
93        }
94        let below = delta_depth(owner as usize, blobs, blob_of_chunk, memo, visiting)?;
95        depth = depth.max(below.saturating_add(1));
96        if depth > MAX_DELTA_DEPTH {
97            return Err(Error::Limit(format!("delta blob {bi}: dictionary chain deeper than {MAX_DELTA_DEPTH}")));
98        }
99    }
100    visiting[bi] = false;
101    memo[bi] = depth;
102    Ok(depth)
103}
104
105/// Parse and validate the container framing: header, trailer, section table (CRC) and every
106/// section's BLAKE3 hash. Needs no credentials.
107fn framing(data: &[u8]) -> Result<(Header, Vec<SectionEntry>)> {
108    if data.len() < format::HEADER_LEN + format::TRAILER_LEN {
109        return Err(Error::Corrupt("file too short".into()));
110    }
111    let header = format::decode_header(&data[..format::HEADER_LEN])?;
112    let (toff, tlen, crc) = format::decode_trailer(&data[data.len() - format::TRAILER_LEN..])?;
113    let tend = toff.checked_add(tlen).ok_or_else(|| Error::Corrupt("table range".into()))?;
114    if toff < format::HEADER_LEN as u64 || tend != (data.len() - format::TRAILER_LEN) as u64 {
115        return Err(Error::Corrupt("section table does not end at the trailer".into()));
116    }
117    let table = &data[toff as usize..tend as usize];
118    if format::crc32(table) != crc {
119        return Err(Error::Corrupt("section table crc".into()));
120    }
121    let sections: Vec<SectionEntry> = format::cbor_decode(table)?;
122
123    // Every section: inside the file, non-overlapping, hash verified.
124    let mut cursor = format::HEADER_LEN as u64;
125    for s in &sections {
126        let end = s.off.checked_add(s.len).ok_or_else(|| Error::Corrupt("section range overflow".into()))?;
127        if s.off < cursor || end > toff {
128            return Err(Error::Corrupt(format!("section {} out of bounds or overlapping", s.t)));
129        }
130        if s.len as usize > READER_MAX_SECTION && s.t != section::BLOBS {
131            return Err(Error::Limit(format!("section {} too large", s.t)));
132        }
133        let bytes = &data[s.off as usize..(s.off + s.len) as usize];
134        if chunk::hash(bytes)[..] != s.h[..] {
135            return Err(Error::HashMismatch(format!("section {}", s.t)));
136        }
137        cursor = s.off + s.len;
138    }
139    Ok((header, sections))
140}
141
142/// What can be learned about an archive without credentials: framing, sections, how it can be
143/// unlocked (stanza types and KDF parameters) and whether it is signed.
144#[derive(Clone, Debug, Serialize)]
145pub struct Inspect {
146    pub version: u16,
147    pub encrypted: bool,
148    pub signed: bool,
149    pub file_bytes: u64,
150    pub sections: Vec<SectionEntry>,
151    pub stanzas: Vec<StanzaInfo>,
152    pub signature: Option<SignatureBlock>,
153}
154
155#[derive(Clone, Debug, Serialize)]
156pub struct StanzaInfo {
157    pub t: String,
158    pub m_kib: u32,
159    pub t_cost: u32,
160    pub p: u32,
161}
162
163pub fn inspect(path: &Path) -> Result<Inspect> {
164    let file = std::fs::File::open(path)?;
165    let data = Backing::Map(unsafe { memmap2::Mmap::map(&file)? });
166    let (header, sections) = framing(&data)?;
167    let find = |t: u8| sections.iter().find(|s| s.t == t).map(|s| &data[s.off as usize..(s.off + s.len) as usize]);
168    let stanzas = match find(section::RECIPIENTS) {
169        Some(b) => {
170            let block: RecipientsBlock = format::cbor_decode(b)?;
171            block.stanzas.iter().map(|s| StanzaInfo { t: s.t.clone(), m_kib: s.m_kib, t_cost: s.t_cost, p: s.p }).collect()
172        }
173        None => Vec::new(),
174    };
175    let signature = match find(section::SIGNATURES) {
176        Some(b) => Some(format::cbor_decode::<SignatureBlock>(b)?),
177        None => None,
178    };
179    Ok(Inspect {
180        version: header.version,
181        encrypted: header.flags & format::FLAG_ENCRYPTED != 0,
182        signed: header.flags & format::FLAG_SIGNED != 0,
183        file_bytes: data.len() as u64,
184        sections,
185        stanzas,
186        signature,
187    })
188}
189
190impl Reader {
191    /// Open an archive file (memory-mapped; nothing is decoded before the framing and every
192    /// section hash have been verified).
193    pub fn open(path: &Path, password: Option<&str>) -> Result<Reader> {
194        Self::open_with_refs(path, password, &[])
195    }
196
197    /// Open an archive together with the reference archives that hold its external chunks.
198    pub fn open_with_refs(path: &Path, password: Option<&str>, references: &[PathBuf]) -> Result<Reader> {
199        let creds = Credentials { password: password.map(|s| s.to_string()), identities: Vec::new() };
200        Self::open_with(path, &creds, references)
201    }
202
203    /// Open with full credentials (passphrase and/or hybrid identities) and reference archives.
204    pub fn open_with(path: &Path, creds: &Credentials, references: &[PathBuf]) -> Result<Reader> {
205        let mut r = Self::open_one(path, creds)?;
206        for rp in references {
207            let rr = Self::open_one(rp, creds).map_err(|e| Error::Missing(format!("reference archive {}: {e}", rp.display())))?;
208            let ri = r.refs.len();
209            for (ci, c) in rr.index.chunks.iter().enumerate() {
210                if c.x == 0 && c.h.len() == 32 {
211                    let mut h = [0u8; 32];
212                    h.copy_from_slice(&c.h);
213                    r.ref_lookup.entry(h).or_insert((ri, ci as u32));
214                }
215            }
216            r.refs.push(rr);
217        }
218        Ok(r)
219    }
220
221    /// Number of external chunks that cannot be resolved with the references given so far.
222    pub fn unresolved_external(&self) -> usize {
223        self.index
224            .chunks
225            .iter()
226            .filter(|c| c.x != 0)
227            .filter(|c| {
228                let mut h = [0u8; 32];
229                if c.h.len() != 32 {
230                    return true;
231                }
232                h.copy_from_slice(&c.h);
233                !self.ref_lookup.contains_key(&h)
234            })
235            .count()
236    }
237
238    fn open_one(path: &Path, creds: &Credentials) -> Result<Reader> {
239        let file = std::fs::File::open(path)?;
240        // SAFETY: the mapping is read-only; concurrent modification of the file by another process
241        // would be detected by the section hashes (or cause a hash-mismatch error), never memory unsafety
242        // in this crate's own logic because every access is bounds-checked against the mapped length.
243        let map = unsafe { memmap2::Mmap::map(&file)? };
244        Self::from_backing(Backing::Map(map), creds)
245    }
246
247    pub fn from_bytes(data: Vec<u8>, password: Option<&str>) -> Result<Reader> {
248        let creds = Credentials { password: password.map(|s| s.to_string()), identities: Vec::new() };
249        Self::from_backing(Backing::Mem(data), &creds)
250    }
251
252    fn from_backing(data: Backing, creds: &Credentials) -> Result<Reader> {
253        if data.len() < format::HEADER_LEN + format::TRAILER_LEN {
254            return Err(Error::Corrupt("file too short".into()));
255        }
256        let (header, sections) = framing(&data)?;
257        let find = |t: u8| sections.iter().find(|s| s.t == t).map(|s| &data[s.off as usize..(s.off + s.len) as usize]);
258
259        let key = if header.flags & format::FLAG_ENCRYPTED != 0 {
260            if creds.password.is_none() && creds.identities.is_empty() {
261                return Err(Error::Crypto("archive is encrypted: password or identity required".into()));
262            }
263            let block: RecipientsBlock = format::cbor_decode(find(section::RECIPIENTS).ok_or_else(|| Error::Corrupt("missing recipients section".into()))?)?;
264            Some(crypto::unlock(&block, creds)?)
265        } else {
266            None
267        };
268
269        let open_section = |label: u8, aad: &[u8], bytes: &[u8]| -> Result<Vec<u8>> {
270            match &key {
271                Some(k) => crypto::open(k, label, 0, aad, bytes),
272                None => Ok(bytes.to_vec()),
273            }
274        };
275
276        let index_raw = open_section(crypto::LABEL_INDEX, b"index", find(section::CHUNK_INDEX).ok_or_else(|| Error::Corrupt("missing chunk index".into()))?)?;
277        let index: ChunkIndex = format::cbor_decode(&zstd_unwrap(&index_raw, READER_MAX_SECTION)?)?;
278        let manifest_raw = open_section(crypto::LABEL_MANIFEST, b"manifest", find(section::MANIFEST).ok_or_else(|| Error::Corrupt("missing manifest".into()))?)?;
279        let manifest: Manifest = format::cbor_decode(&zstd_unwrap(&manifest_raw, READER_MAX_SECTION)?)?;
280        if manifest.entries.len() > READER_MAX_ENTRIES {
281            return Err(Error::Limit(format!("{} entries exceed the reader limit of {READER_MAX_ENTRIES}", manifest.entries.len())));
282        }
283        if manifest.tsaur != 1 {
284            return Err(Error::Version(manifest.tsaur));
285        }
286        let dict = match find(section::DICTIONARY) {
287            Some(b) => Some(open_section(crypto::LABEL_DICT, b"dict", b)?),
288            None => None,
289        };
290        let blobs_off = sections.iter().find(|s| s.t == section::BLOBS).map(|s| s.off).ok_or_else(|| Error::Corrupt("missing blobs section".into()))?;
291        let signature = match find(section::SIGNATURES) {
292            Some(b) => Some(format::cbor_decode::<SignatureBlock>(b)?),
293            None => None,
294        };
295
296        // Limits: declared sizes vs. reader ceilings, bomb ratio, index consistency.
297        let total: u64 = index.blobs.iter().map(|b| b.ulen as u64).sum();
298        let max_total = manifest.limits.max_total.min(READER_MAX_TOTAL);
299        if total > max_total {
300            return Err(Error::Limit(format!("declared {total} bytes exceed limit {max_total}")));
301        }
302        let ratio = total / (data.len() as u64).max(1);
303        if ratio > manifest.limits.max_ratio as u64 {
304            return Err(Error::Limit(format!("expansion ratio {ratio}x exceeds limit")));
305        }
306        let mut blob_of_chunk = vec![u32::MAX; index.chunks.len()];
307        let blobs_len = sections.iter().find(|s| s.t == section::BLOBS).map(|s| s.len).unwrap_or(0);
308        for (bi, b) in index.blobs.iter().enumerate() {
309            if b.off.checked_add(b.clen as u64).is_none_or(|e| e > blobs_len) {
310                return Err(Error::Corrupt(format!("blob {bi} out of bounds")));
311            }
312            if b.n.is_empty() || b.n.iter().any(|&ci| ci as usize >= index.chunks.len()) {
313                return Err(Error::Corrupt(format!("blob {bi} references chunks out of range")));
314            }
315            let sum: u64 = b.n.iter().map(|&ci| index.chunks[ci as usize].s as u64).sum();
316            if sum != b.ulen as u64 {
317                return Err(Error::Corrupt(format!("blob {bi} size does not match its chunks")));
318            }
319            for &ci in &b.n {
320                if blob_of_chunk[ci as usize] != u32::MAX {
321                    return Err(Error::Corrupt(format!("chunk {ci} referenced by two blobs")));
322                }
323                blob_of_chunk[ci as usize] = bi as u32;
324            }
325        }
326        for (ci, c) in index.chunks.iter().enumerate() {
327            if c.h.len() != 32 {
328                return Err(Error::Corrupt(format!("chunk {ci}: bad hash length")));
329            }
330            if c.x == 0 && blob_of_chunk[ci] == u32::MAX {
331                return Err(Error::Corrupt(format!("chunk {ci} without blob")));
332            }
333            if c.x != 0 && blob_of_chunk[ci] != u32::MAX {
334                return Err(Error::Corrupt(format!("chunk {ci} marked external but stored")));
335            }
336        }
337        // Delta blobs: dictionary chunks in range and bounded; the dependency graph between blobs
338        // (a delta blob depends on the blobs storing its dictionary chunks) must be acyclic and at
339        // most MAX_DELTA_DEPTH deep, so decoding always terminates after a bounded amount of work.
340        for (bi, b) in index.blobs.iter().enumerate() {
341            if b.codec != codec::ZSTD_DELTA {
342                continue;
343            }
344            if b.d.is_empty() || b.d.iter().any(|&ci| ci as usize >= index.chunks.len()) {
345                return Err(Error::Corrupt(format!("delta blob {bi}: dictionary chunks out of range")));
346            }
347            let dict_bytes: u64 = b.d.iter().map(|&ci| index.chunks[ci as usize].s as u64).sum();
348            if dict_bytes > 64 << 20 {
349                return Err(Error::Limit(format!("delta blob {bi}: dictionary larger than 64 MiB")));
350            }
351        }
352        let mut memo = vec![0u8; index.blobs.len()];
353        let mut visiting = vec![false; index.blobs.len()];
354        for bi in 0..index.blobs.len() {
355            if index.blobs[bi].codec == codec::ZSTD_DELTA {
356                delta_depth(bi, &index.blobs, &blob_of_chunk, &mut memo, &mut visiting)?;
357            }
358        }
359        for e in &manifest.entries {
360            paths::normalize(&e.path)?;
361            let refs: Box<dyn Iterator<Item = &u32>> = match (&e.zip, &e.streams) {
362                (Some(z), _) => Box::new(z.members.iter().flat_map(|m| m.chunks.iter())),
363                (None, Some(s)) => Box::new(s.segments.iter().flat_map(|m| m.chunks.iter())),
364                (None, None) => Box::new(e.chunks.iter()),
365            };
366            for &ci in refs {
367                if ci as usize >= index.chunks.len() {
368                    return Err(Error::Corrupt(format!("{}: chunk reference out of range", e.path)));
369                }
370            }
371        }
372
373        Ok(Reader { data, header, sections, manifest, index, dict, key, blobs_off, blob_of_chunk, cache: HashMap::new(), signature, refs: Vec::new(), ref_lookup: HashMap::new() })
374    }
375
376    pub fn is_encrypted(&self) -> bool {
377        self.key.is_some()
378    }
379
380    pub fn signature(&self) -> Option<&SignatureBlock> {
381        self.signature.as_ref()
382    }
383
384    /// Verify the embedded signature against a public key (hex or raw 32 bytes).
385    pub fn verify_signature(&self, pubkey: &[u8; 32]) -> Result<bool> {
386        let s = self.signature.as_ref().ok_or_else(|| Error::Missing("archive is not signed".into()))?;
387        if s.alg != "ed25519" {
388            return Err(Error::Crypto(format!("unsupported signature algorithm {}", s.alg)));
389        }
390        if s.key[..] != pubkey[..] {
391            return Ok(false);
392        }
393        let msg = format::signing_message(&self.data[..format::HEADER_LEN], &self.sections);
394        Ok(crypto::sig::verify(pubkey, &msg, &s.sig))
395    }
396
397    fn blob_plain(&mut self, bi: u32) -> Result<Arc<Vec<u8>>> {
398        if let Some(p) = self.cache.get(&bi) {
399            return Ok(p.clone());
400        }
401        let b = self.index.blobs[bi as usize].clone();
402        let start = (self.blobs_off + b.off) as usize;
403        let stored = &self.data[start..start + b.clen as usize];
404        let aad = blob_aad(b.codec, b.ulen, b.n[0], b.n.len() as u32);
405        let compressed = match &self.key {
406            Some(k) => crypto::open(k, crypto::LABEL_BLOB, bi as u64, &aad, stored)?,
407            None => stored.to_vec(),
408        };
409        let plain = if b.codec == codec::ZSTD_DELTA {
410            // the dictionary is an earlier version of this content: chunks of this archive or of a reference
411            let mut prefix = Vec::with_capacity(b.d.iter().map(|&ci| self.index.chunks[ci as usize].s as usize).sum());
412            for &ci in &b.d {
413                prefix.extend_from_slice(&self.chunk(ci)?);
414            }
415            codec::zstd_decompress_with_prefix(&compressed, b.ulen as usize, &prefix)?
416        } else {
417            codec::decompress_with(b.codec, &compressed, b.ulen as usize, self.dict.as_deref(), &b.p, b.f)?
418        };
419        // Verify every chunk hash inside the blob.
420        let mut off = 0usize;
421        for &ci in &b.n {
422            let c = &self.index.chunks[ci as usize];
423            let piece = &plain[off..off + c.s as usize];
424            if chunk::hash(piece)[..] != c.h[..] {
425                return Err(Error::HashMismatch(format!("chunk {ci}")));
426            }
427            off += c.s as usize;
428        }
429        let arc = Arc::new(plain);
430        // bounded plaintext cache: keeps memory predictable for large archives
431        let cached: usize = self.cache.values().map(|v| v.len()).sum();
432        if cached + arc.len() > CACHE_BYTES {
433            self.cache.clear();
434        }
435        self.cache.insert(bi, arc.clone());
436        Ok(arc)
437    }
438
439    /// Stream the bytes of an entry to `sink` (raw entries chunk by chunk; container entries rebuilt)
440    /// and verify the entry hash; returns the number of bytes written.
441    pub fn stream_entry(&mut self, ei: usize, sink: &mut dyn std::io::Write) -> Result<u64> {
442        let e = self.manifest.entries.get(ei).cloned().ok_or_else(|| Error::Missing(format!("entry {ei}")))?;
443        if e.mode != "raw" {
444            let bytes = self.entry_bytes(ei)?;
445            sink.write_all(&bytes)?;
446            return Ok(bytes.len() as u64);
447        }
448        let mut hasher = blake3::Hasher::new();
449        let mut total = 0u64;
450        for &ci in &e.chunks {
451            let c = self.chunk(ci)?;
452            hasher.update(&c);
453            total += c.len() as u64;
454            sink.write_all(&c)?;
455        }
456        if total != e.size || hasher.finalize().as_bytes()[..] != e.h[..] {
457            return Err(Error::HashMismatch(e.path.clone()));
458        }
459        Ok(total)
460    }
461
462    /// Plaintext of one chunk (verified); external chunks are fetched from the reference archives.
463    pub fn chunk(&mut self, ci: u32) -> Result<Vec<u8>> {
464        let c = &self.index.chunks[ci as usize];
465        if c.x != 0 {
466            let mut h = [0u8; 32];
467            h.copy_from_slice(&c.h);
468            let (ri, cj) = *self.ref_lookup.get(&h).ok_or_else(|| Error::Missing(format!("chunk {ci} is external (b3:{}…) and no reference archive provides it", hex::encode(&h[..6]))))?;
469            let data = self.refs[ri].chunk(cj)?;
470            if chunk::hash(&data) != h {
471                return Err(Error::HashMismatch(format!("external chunk {ci}")));
472            }
473            return Ok(data);
474        }
475        let bi = self.blob_of_chunk[ci as usize];
476        let b = &self.index.blobs[bi as usize];
477        let mut off = 0usize;
478        for &k in &b.n {
479            if k == ci {
480                break;
481            }
482            off += self.index.chunks[k as usize].s as usize;
483        }
484        let len = self.index.chunks[ci as usize].s as usize;
485        let plain = self.blob_plain(bi)?;
486        Ok(plain[off..off + len].to_vec())
487    }
488
489    fn stream(&mut self, refs: &[u32]) -> Result<Vec<u8>> {
490        let mut out = Vec::new();
491        for &ci in refs {
492            out.extend_from_slice(&self.chunk(ci)?);
493        }
494        Ok(out)
495    }
496
497    /// Full bytes of an entry, verified against its BLAKE3 hash.
498    pub fn entry_bytes(&mut self, ei: usize) -> Result<Vec<u8>> {
499        let e = self.manifest.entries.get(ei).cloned().ok_or_else(|| Error::Missing(format!("entry {ei}")))?;
500        let data = match e.mode.as_str() {
501            "raw" => self.stream(&e.chunks)?,
502            "zip" => {
503                let z = e.zip.as_ref().ok_or_else(|| Error::Corrupt("zip entry without recipe".into()))?;
504                let mut parts = Vec::with_capacity(z.members.len());
505                for m in &z.members {
506                    parts.push(self.stream(&m.chunks)?);
507                }
508                container::rebuild(z, &parts)?
509            }
510            "streams" => {
511                let s = e.streams.as_ref().ok_or_else(|| Error::Corrupt("streams entry without recipe".into()))?;
512                let mut parts = Vec::with_capacity(s.segments.len());
513                for seg in &s.segments {
514                    parts.push(self.stream(&seg.chunks)?);
515                }
516                container::rebuild_streams(s, &parts)?
517            }
518            other => return Err(Error::Corrupt(format!("unknown entry mode {other}"))),
519        };
520        if data.len() as u64 != e.size || chunk::hash(&data)[..] != e.h[..] {
521            return Err(Error::HashMismatch(e.path.clone()));
522        }
523        Ok(data)
524    }
525
526    /// Byte range of a raw entry without materialising the whole file (zip entries are rebuilt).
527    pub fn read_range(&mut self, ei: usize, start: u64, len: usize) -> Result<Vec<u8>> {
528        let e = self.manifest.entries.get(ei).cloned().ok_or_else(|| Error::Missing(format!("entry {ei}")))?;
529        if e.mode != "raw" {
530            let all = self.entry_bytes(ei)?;
531            let s = (start as usize).min(all.len());
532            let t = (s + len).min(all.len());
533            return Ok(all[s..t].to_vec());
534        }
535        let end = start.saturating_add(len as u64).min(e.size);
536        let mut out = Vec::new();
537        let mut pos = 0u64;
538        for &ci in &e.chunks {
539            let sz = self.index.chunks[ci as usize].s as u64;
540            let (cs, ce) = (pos, pos + sz);
541            if ce > start && cs < end {
542                let c = self.chunk(ci)?;
543                let from = start.saturating_sub(cs) as usize;
544                let to = (end - cs).min(sz) as usize;
545                out.extend_from_slice(&c[from..to]);
546            }
547            pos = ce;
548            if pos >= end {
549                break;
550            }
551        }
552        Ok(out)
553    }
554
555    pub fn find_entry(&self, path: &str) -> Option<usize> {
556        let norm = paths::normalize(path).ok()?;
557        self.manifest.entries.iter().position(|e| e.path == norm)
558    }
559
560    pub fn entries(&self) -> &[Entry] {
561        &self.manifest.entries
562    }
563
564    /// Extract everything (or the selected entry indices) under `dir`, with sanitised paths.
565    /// Raw entries stream chunk by chunk to a temporary file that is renamed into place only after
566    /// the hash verified; container entries are rebuilt in memory. Existing files are replaced
567    /// only with `overwrite`; every destination is checked before the first byte is written, so
568    /// a refusal leaves the directory as it was.
569    pub fn extract(&mut self, dir: &Path, selected: Option<&[usize]>, overwrite: bool) -> Result<Vec<PathBuf>> {
570        let idxs: Vec<usize> = match selected {
571            Some(s) => s.to_vec(),
572            // stored views (.tsaur/views/…) are extracted only when selected explicitly
573            None => (0..self.manifest.entries.len()).filter(|&i| self.manifest.entries[i].derived.is_none()).collect(),
574        };
575        for &i in &idxs {
576            let e = self.manifest.entries.get(i).ok_or_else(|| Error::Missing(format!("entry {i}")))?;
577            let dest = paths::safe_join(dir, &e.path)?;
578            match std::fs::symlink_metadata(&dest) {
579                Ok(m) if m.is_dir() => return Err(Error::Policy(format!("{} exists and is a directory", dest.display()))),
580                Ok(_) if !overwrite => return Err(Error::Policy(format!("{} exists; pass --overwrite to replace existing files", dest.display()))),
581                _ => {}
582            }
583        }
584        // On a case-insensitive filesystem two entries that differ only by letter case would
585        // overwrite each other silently; refuse before writing anything.
586        if cfg!(any(windows, target_os = "macos")) {
587            let mut seen = std::collections::HashSet::new();
588            for &i in &idxs {
589                let p = &self.manifest.entries.get(i).ok_or_else(|| Error::Missing(format!("entry {i}")))?.path;
590                if !seen.insert(p.to_lowercase()) {
591                    return Err(Error::Policy(format!("entries differ only by letter case ({p}): refusing to extract on a case-insensitive filesystem; select entries explicitly")));
592                }
593            }
594        }
595        let mut written = Vec::new();
596        for ei in idxs {
597            let e = self.manifest.entries.get(ei).cloned().ok_or_else(|| Error::Missing(format!("entry {ei}")))?;
598            let dest = paths::safe_join(dir, &e.path)?;
599            if let Some(parent) = dest.parent() {
600                std::fs::create_dir_all(parent)?;
601            }
602            let tmp = dest.with_extension(format!("{}.tsr-partial", dest.extension().map(|x| x.to_string_lossy().to_string()).unwrap_or_default()));
603            {
604                let mut f = std::io::BufWriter::with_capacity(1 << 20, std::fs::File::create(&tmp)?);
605                let res = self.stream_entry(ei, &mut f).and_then(|_| std::io::Write::flush(&mut f).map_err(Error::from));
606                drop(f);
607                if let Err(err) = res {
608                    let _ = std::fs::remove_file(&tmp);
609                    return Err(err);
610                }
611            }
612            if dest.exists() {
613                std::fs::remove_file(&dest)?;
614            }
615            std::fs::rename(&tmp, &dest)?;
616            written.push(dest);
617        }
618        Ok(written)
619    }
620
621    /// Decompress and hash-check every entry; verify the signature if a public key is given.
622    pub fn verify(&mut self, pubkey: Option<&[u8; 32]>) -> Result<VerifyReport> {
623        let mut bad = Vec::new();
624        let n = self.manifest.entries.len();
625        for ei in 0..n {
626            if let Err(e) = self.stream_entry(ei, &mut std::io::sink()) {
627                bad.push(format!("{}: {}", self.manifest.entries[ei].path, e));
628            }
629        }
630        let signature = self.signature.as_ref().map(|s| hex::encode(&s.key));
631        let signature_valid = match (pubkey, &self.signature) {
632            (Some(pk), Some(_)) => Some(self.verify_signature(pk)?),
633            _ => None,
634        };
635        Ok(VerifyReport { sections_ok: true, entries_total: n, entries_ok: n - bad.len(), entries_bad: bad, blobs: self.index.blobs.len(), signature, signature_valid })
636    }
637}