Skip to main content

tsaur_core/
volumes.rs

1//! Offline volume sets: one archive file striped across `N` data volumes plus `M` parity volumes
2//! (systematic Reed-Solomon over GF(2^8), one piece per volume per stripe), so that the archive
3//! can be spread over directories or removable drives and rebuilt from any `N` of the `N + M`
4//! volumes. Every volume carries the complete descriptor (geometry, archive hash, every piece
5//! hash), so no separate index file is needed. See `docs/design/VOLUME-SETS.md`.
6//!
7//! Everything streams one stripe at a time: memory stays at `(N + M) × piece_size` whatever the
8//! archive size. The archive itself is treated as opaque bytes; `join` verifies the rebuilt file
9//! against the archive hash recorded at split time before giving it its final name.
10
11use crate::chunk::{self, Hash};
12use crate::error::{Error, Result};
13use crate::format;
14use reed_solomon_erasure::galois_8::ReedSolomon;
15use serde::{Deserialize, Serialize};
16use serde_bytes::ByteBuf;
17use std::collections::BTreeMap;
18use std::fs::File;
19use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write};
20use std::path::{Path, PathBuf};
21
22pub const VOLUME_MAGIC: [u8; 4] = *b"TSV\x1A";
23pub const TRAILER_MAGIC: [u8; 4] = *b"VST\x1A";
24pub const VERSION: u16 = 1;
25pub const HEADER_LEN: usize = 64;
26pub const TRAILER_LEN: usize = 56;
27pub const MIN_PIECE_SIZE: u32 = 64 << 10;
28pub const MAX_PIECE_SIZE: u32 = 64 << 20;
29pub const DEFAULT_PIECE_SIZE: u32 = 1 << 20;
30pub const MAX_VOLUMES: usize = 256;
31/// Descriptors above this size are refused before allocation.
32pub const MAX_DESCRIPTOR: u64 = 64 << 20;
33/// Most data pieces a set may have (bounds descriptor size, memory and index arithmetic); larger
34/// archives need a larger piece size.
35pub const MAX_PIECES: u32 = 2_000_000;
36/// Longest archive name accepted in a descriptor.
37pub const MAX_ARCHIVE_NAME: usize = 255;
38pub const EXTENSION: &str = "tsrv";
39
40/// The descriptor stored (identically) in every volume of a set.
41#[derive(Clone, Debug, Serialize, Deserialize)]
42pub struct VolumeSet {
43    pub v: u16,
44    pub set_id: ByteBuf,
45    pub archive_name: String,
46    pub archive_size: u64,
47    /// BLAKE3 of the whole archive file: `join` verifies the rebuilt file against it.
48    pub archive_b3: ByteBuf,
49    pub piece_size: u32,
50    pub data: u16,
51    pub parity: u16,
52    /// Number of real data pieces (`ceil(archive_size / piece_size)`).
53    pub pieces: u32,
54    /// `ceil(pieces / data)`.
55    pub stripes: u32,
56    pub piece_hashes: Vec<ByteBuf>,
57    /// `stripes * parity` hashes, index `stripe * parity + j`.
58    pub parity_hashes: Vec<ByteBuf>,
59    /// Merkle root over the data piece hashes.
60    pub root: ByteBuf,
61    pub generator: String,
62}
63
64impl VolumeSet {
65    pub fn total(&self) -> usize {
66        self.data as usize + self.parity as usize
67    }
68
69    pub fn is_parity(&self, index: usize) -> bool {
70        index >= self.data as usize
71    }
72
73    /// Number of pieces stored on volume `index`.
74    pub fn volume_pieces(&self, index: usize) -> u64 {
75        if self.is_parity(index) {
76            self.stripes as u64
77        } else {
78            let (p, n) = (self.pieces as u64, self.data as u64);
79            let v = index as u64;
80            if v >= p {
81                0
82            } else {
83                (p - v).div_ceil(n)
84            }
85        }
86    }
87
88    pub fn payload_len(&self, index: usize) -> u64 {
89        self.volume_pieces(index) * self.piece_size as u64
90    }
91
92    /// File name of volume `index` (1-based in the name, data volumes first). The archive name
93    /// comes from the descriptor, which may have arrived from another machine: it is used only
94    /// when it is a safe single path component (no separators, colons, control characters,
95    /// reserved device names or trailing dots/spaces); otherwise the set id names the files.
96    pub fn volume_name(&self, index: usize) -> String {
97        let width = if self.total() > 99 { 3 } else { 2 };
98        let stem = match crate::paths::normalize(&self.archive_name) {
99            Ok(n) if !n.contains('/') && n.len() <= MAX_ARCHIVE_NAME => n,
100            _ => format!("set-{}", hex::encode(&self.set_id[..8])),
101        };
102        format!("{stem}.v{:0width$}.{EXTENSION}", index + 1, width = width)
103    }
104
105    pub fn validate(&self) -> Result<()> {
106        let bad = |m: &str| Error::Corrupt(format!("volume descriptor: {m}"));
107        if self.v != VERSION {
108            return Err(Error::Version(self.v));
109        }
110        if self.data == 0 || self.total() > MAX_VOLUMES {
111            return Err(bad("volume counts out of range"));
112        }
113        if !(MIN_PIECE_SIZE..=MAX_PIECE_SIZE).contains(&self.piece_size) {
114            return Err(bad("piece size out of range"));
115        }
116        if self.set_id.len() != 32 || self.archive_b3.len() != 32 || self.root.len() != 32 {
117            return Err(bad("hash lengths"));
118        }
119        let pieces = self.archive_size.div_ceil(self.piece_size as u64);
120        if pieces > MAX_PIECES as u64 {
121            return Err(Error::Limit(format!("volume descriptor: {pieces} pieces exceed the limit of {MAX_PIECES}")));
122        }
123        if pieces != self.pieces as u64 || pieces.div_ceil(self.data as u64) != self.stripes as u64 {
124            return Err(bad("piece or stripe count does not match the archive size"));
125        }
126        if self.piece_hashes.len() != self.pieces as usize || self.parity_hashes.len() != self.stripes as usize * self.parity as usize {
127            return Err(bad("hash list lengths"));
128        }
129        if self.piece_hashes.iter().chain(self.parity_hashes.iter()).any(|h| h.len() != 32) {
130            return Err(bad("hash length"));
131        }
132        if self.archive_name.is_empty() || self.archive_name.len() > MAX_ARCHIVE_NAME || self.archive_name.contains(['/', '\\']) || self.archive_name.chars().any(|c| c.is_control()) {
133            return Err(bad("archive name"));
134        }
135        Ok(())
136    }
137
138    pub(crate) fn piece_hash(&self, index: u32) -> [u8; 32] {
139        let mut h = [0u8; 32];
140        h.copy_from_slice(&self.piece_hashes[index as usize]);
141        h
142    }
143
144    pub(crate) fn parity_hash(&self, stripe: u32, j: usize) -> [u8; 32] {
145        let mut h = [0u8; 32];
146        h.copy_from_slice(&self.parity_hashes[stripe as usize * self.parity as usize + j]);
147        h
148    }
149}
150
151/// The fixed header at the start of every volume.
152#[derive(Clone, Copy, Debug, PartialEq, Eq)]
153pub struct VolumeHeader {
154    pub index: u16,
155    pub data: u16,
156    pub parity: u16,
157    pub piece_size: u32,
158    pub archive_size: u64,
159    pub set_id: [u8; 32],
160    pub payload_len: u64,
161}
162
163pub(crate) fn encode_header(h: &VolumeHeader) -> [u8; HEADER_LEN] {
164    let mut b = [0u8; HEADER_LEN];
165    b[0..4].copy_from_slice(&VOLUME_MAGIC);
166    b[4..6].copy_from_slice(&VERSION.to_le_bytes());
167    b[6..8].copy_from_slice(&h.index.to_le_bytes());
168    b[8..10].copy_from_slice(&h.data.to_le_bytes());
169    b[10..12].copy_from_slice(&h.parity.to_le_bytes());
170    b[12..16].copy_from_slice(&h.piece_size.to_le_bytes());
171    b[16..24].copy_from_slice(&h.archive_size.to_le_bytes());
172    b[24..56].copy_from_slice(&h.set_id);
173    b[56..64].copy_from_slice(&h.payload_len.to_le_bytes());
174    b
175}
176
177pub(crate) fn decode_header(b: &[u8]) -> Result<VolumeHeader> {
178    if b.len() < HEADER_LEN || b[0..4] != VOLUME_MAGIC {
179        return Err(Error::BadMagic);
180    }
181    let version = u16::from_le_bytes([b[4], b[5]]);
182    if version != VERSION {
183        return Err(Error::Version(version));
184    }
185    let mut set_id = [0u8; 32];
186    set_id.copy_from_slice(&b[24..56]);
187    Ok(VolumeHeader {
188        index: u16::from_le_bytes([b[6], b[7]]),
189        data: u16::from_le_bytes([b[8], b[9]]),
190        parity: u16::from_le_bytes([b[10], b[11]]),
191        piece_size: u32::from_le_bytes(b[12..16].try_into().unwrap()),
192        archive_size: u64::from_le_bytes(b[16..24].try_into().unwrap()),
193        set_id,
194        payload_len: u64::from_le_bytes(b[56..64].try_into().unwrap()),
195    })
196}
197
198pub(crate) fn encode_trailer(desc_off: u64, desc_len: u64, desc_hash: &Hash, index: u16) -> [u8; TRAILER_LEN] {
199    let mut b = [0u8; TRAILER_LEN];
200    b[0..8].copy_from_slice(&desc_off.to_le_bytes());
201    b[8..16].copy_from_slice(&desc_len.to_le_bytes());
202    b[16..48].copy_from_slice(desc_hash);
203    b[48..50].copy_from_slice(&index.to_le_bytes());
204    b[52..56].copy_from_slice(&TRAILER_MAGIC);
205    b
206}
207
208/// (descriptor offset, descriptor length, descriptor hash, volume index)
209fn decode_trailer(b: &[u8]) -> Result<(u64, u64, Hash, u16)> {
210    if b.len() != TRAILER_LEN || b[52..56] != TRAILER_MAGIC {
211        return Err(Error::Corrupt("volume trailer".into()));
212    }
213    let mut h = [0u8; 32];
214    h.copy_from_slice(&b[16..48]);
215    Ok((u64::from_le_bytes(b[0..8].try_into().unwrap()), u64::from_le_bytes(b[8..16].try_into().unwrap()), h, u16::from_le_bytes([b[48], b[49]])))
216}
217
218pub(crate) fn set_id_for(archive_b3: &Hash, archive_size: u64, piece_size: u32, data: u16, parity: u16) -> Hash {
219    let mut h = blake3::Hasher::new_derive_key("tsaur volume set v1");
220    h.update(archive_b3);
221    h.update(&archive_size.to_le_bytes());
222    h.update(&piece_size.to_le_bytes());
223    h.update(&data.to_le_bytes());
224    h.update(&parity.to_le_bytes());
225    *h.finalize().as_bytes()
226}
227
228fn read_full<R: Read>(r: &mut R, buf: &mut [u8]) -> Result<usize> {
229    let mut got = 0;
230    while got < buf.len() {
231        let n = r.read(&mut buf[got..])?;
232        if n == 0 {
233            break;
234        }
235        got += n;
236    }
237    Ok(got)
238}
239
240fn hash_file(path: &Path) -> Result<(Hash, u64)> {
241    let mut f = BufReader::with_capacity(1 << 20, File::open(path)?);
242    let mut hasher = blake3::Hasher::new();
243    let mut buf = vec![0u8; 1 << 20];
244    let mut total = 0u64;
245    loop {
246        let n = f.read(&mut buf)?;
247        if n == 0 {
248            break;
249        }
250        hasher.update(&buf[..n]);
251        total += n as u64;
252    }
253    Ok((*hasher.finalize().as_bytes(), total))
254}
255
256fn rs_new(k: usize, m: usize) -> Result<Option<ReedSolomon>> {
257    if m == 0 {
258        return Ok(None);
259    }
260    ReedSolomon::new(k, m).map(Some).map_err(|e| Error::Invalid(format!("reed-solomon: {e:?}")))
261}
262
263// ---------------------------------------------------------------- split
264
265pub struct SplitOptions {
266    pub data: u16,
267    pub parity: u16,
268    /// Piece size in bytes; `None` selects `adaptive_piece_size` for the archive and geometry.
269    pub piece_size: Option<u32>,
270    /// Directories that receive the volumes, round-robin, data volumes first.
271    pub outputs: Vec<PathBuf>,
272}
273
274/// Target number of pieces per data volume for the adaptive default.
275pub const ADAPTIVE_PIECES_PER_VOLUME: u64 = 16;
276/// Largest adaptive piece size unless the descriptor would otherwise exceed `ADAPTIVE_MAX_PIECES`.
277pub const ADAPTIVE_MAX_PIECE_SIZE: u32 = 1 << 20;
278/// Above this many pieces the adaptive rule grows the piece size (descriptor size and memory).
279pub const ADAPTIVE_MAX_PIECES: u64 = 1_000_000;
280
281/// Default piece size for an archive of `archive_size` bytes split over `data` data volumes: the
282/// smallest power of two between 64 KiB and 1 MiB that gives every data volume at least 16
283/// pieces (so padding stays a few percent even for small archives), grown beyond 1 MiB only
284/// when the archive would otherwise need more than a million pieces. The chosen size is written
285/// into every volume, so readers never depend on this rule.
286pub fn adaptive_piece_size(archive_size: u64, data: u16) -> u32 {
287    let per_volume = archive_size.div_ceil(ADAPTIVE_PIECES_PER_VOLUME * data.max(1) as u64);
288    let mut size = per_volume.max(MIN_PIECE_SIZE as u64).next_power_of_two().min(ADAPTIVE_MAX_PIECE_SIZE as u64);
289    while archive_size.div_ceil(size) > ADAPTIVE_MAX_PIECES && size < MAX_PIECE_SIZE as u64 {
290        size *= 2;
291    }
292    size as u32
293}
294
295#[derive(Clone, Debug, Serialize)]
296pub struct VolumeInfo {
297    pub index: usize,
298    pub kind: &'static str,
299    pub path: PathBuf,
300    pub bytes: u64,
301}
302
303#[derive(Clone, Debug, Serialize)]
304pub struct SplitReport {
305    pub set_id: String,
306    /// BLAKE3 of the descriptor: authenticates every piece hash (see docs/design/VOLUME-TRUST.md).
307    pub descriptor_b3: String,
308    pub archive_b3: String,
309    pub archive_size: u64,
310    pub piece_size: u32,
311    pub data: u16,
312    pub parity: u16,
313    pub pieces: u32,
314    pub stripes: u32,
315    /// The piece size came from `adaptive_piece_size` rather than the caller.
316    pub piece_size_adaptive: bool,
317    pub volumes: Vec<VolumeInfo>,
318    /// Largest number of volumes that share one output location.
319    pub max_per_location: usize,
320    /// True when losing the most loaded location would exceed the parity.
321    pub placement_warning: bool,
322}
323
324/// Where volume `index` of `total` goes when `outputs` locations are given round-robin.
325pub fn placement(outputs: &[PathBuf], index: usize) -> &Path {
326    &outputs[index % outputs.len()]
327}
328
329pub fn split(archive: &Path, opts: &SplitOptions) -> Result<SplitReport> {
330    let (n, m) = (opts.data as usize, opts.parity as usize);
331    if n == 0 || n + m > MAX_VOLUMES {
332        return Err(Error::Invalid(format!("data volumes must be 1..{MAX_VOLUMES} and data + parity at most {MAX_VOLUMES}")));
333    }
334    if let Some(ps) = opts.piece_size {
335        if !(MIN_PIECE_SIZE..=MAX_PIECE_SIZE).contains(&ps) {
336            return Err(Error::Invalid(format!("piece size must be between {MIN_PIECE_SIZE} and {MAX_PIECE_SIZE} bytes")));
337        }
338    }
339    if opts.outputs.is_empty() {
340        return Err(Error::Invalid("at least one output directory is required".into()));
341    }
342    for d in &opts.outputs {
343        if !d.is_dir() {
344            return Err(Error::Missing(format!("output directory {} does not exist", d.display())));
345        }
346    }
347    let archive_name = archive.file_name().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()).ok_or_else(|| Error::Invalid("archive path has no file name".into()))?;
348    let (archive_b3, archive_size) = hash_file(archive)?;
349    let piece_size = opts.piece_size.unwrap_or_else(|| adaptive_piece_size(archive_size, opts.data));
350    let ps = piece_size as usize;
351    let pieces_u64 = archive_size.div_ceil(ps as u64);
352    let pieces = u32::try_from(pieces_u64).ok().filter(|&p| p <= MAX_PIECES).ok_or_else(|| Error::Limit(format!("{pieces_u64} pieces exceed the limit of {MAX_PIECES}: use a larger piece size")))?;
353    let stripes = pieces_u64.div_ceil(n as u64) as u32;
354    let set_id = set_id_for(&archive_b3, archive_size, piece_size, opts.data, opts.parity);
355    let mut set = VolumeSet {
356        v: VERSION,
357        set_id: ByteBuf::from(set_id.to_vec()),
358        archive_name,
359        archive_size,
360        archive_b3: ByteBuf::from(archive_b3.to_vec()),
361        piece_size,
362        data: opts.data,
363        parity: opts.parity,
364        pieces,
365        stripes,
366        piece_hashes: Vec::with_capacity(pieces as usize),
367        parity_hashes: Vec::with_capacity(stripes as usize * m),
368        root: ByteBuf::from(vec![0u8; 32]),
369        generator: crate::WRITER.to_string(),
370    };
371    if MAX_DESCRIPTOR < 32 * (pieces as u64 + stripes as u64 * m as u64) {
372        return Err(Error::Limit("descriptor too large: use a larger piece size".into()));
373    }
374
375    // volume files with their headers
376    let mut writers: Vec<BufWriter<File>> = Vec::with_capacity(n + m);
377    let mut paths = Vec::with_capacity(n + m);
378    for vi in 0..n + m {
379        let path = placement(&opts.outputs, vi).join(set.volume_name(vi));
380        let mut w = BufWriter::with_capacity(1 << 20, File::create(&path)?);
381        let header = VolumeHeader { index: vi as u16, data: opts.data, parity: opts.parity, piece_size, archive_size, set_id, payload_len: set.payload_len(vi) };
382        w.write_all(&encode_header(&header))?;
383        writers.push(w);
384        paths.push(path);
385    }
386
387    // stripe by stripe: hash the data pieces, encode parity, append to the volumes
388    let rs = rs_new(n, m)?;
389    let mut input = BufReader::with_capacity(1 << 20, File::open(archive)?);
390    let mut hashes: Vec<Hash> = Vec::with_capacity(pieces as usize);
391    let mut shards: Vec<Vec<u8>> = (0..n + m).map(|_| vec![0u8; ps]).collect();
392    let mut read_total = 0u64;
393    for s in 0..stripes {
394        for v in 0..n {
395            let pi = s as u64 * n as u64 + v as u64;
396            let shard = &mut shards[v];
397            if pi < pieces as u64 {
398                let got = read_full(&mut input, shard)?;
399                shard[got..].fill(0);
400                read_total += got as u64;
401                hashes.push(chunk::hash(shard));
402                writers[v].write_all(shard)?;
403            } else {
404                shard.fill(0); // virtual piece beyond the end of the archive
405            }
406        }
407        if let Some(rs) = &rs {
408            rs.encode(&mut shards).map_err(|e| Error::Invalid(format!("reed-solomon encode: {e:?}")))?;
409            for j in 0..m {
410                set.parity_hashes.push(ByteBuf::from(chunk::hash(&shards[n + j]).to_vec()));
411                writers[n + j].write_all(&shards[n + j])?;
412            }
413        }
414    }
415    if read_total != archive_size {
416        return Err(Error::Io(std::io::Error::other("archive changed size while it was being split")));
417    }
418    set.root = ByteBuf::from(chunk::merkle_root(&hashes).to_vec());
419    set.piece_hashes = hashes.iter().map(|h| ByteBuf::from(h.to_vec())).collect();
420    set.validate()?;
421
422    // descriptor + trailer on every volume
423    let desc = format::cbor_encode(&set)?;
424    let desc_hash = chunk::hash(&desc);
425    let mut volumes = Vec::with_capacity(n + m);
426    for (vi, mut w) in writers.into_iter().enumerate() {
427        let desc_off = HEADER_LEN as u64 + set.payload_len(vi);
428        w.write_all(&desc)?;
429        w.write_all(&encode_trailer(desc_off, desc.len() as u64, &desc_hash, vi as u16))?;
430        w.flush()?;
431        let bytes = desc_off + desc.len() as u64 + TRAILER_LEN as u64;
432        volumes.push(VolumeInfo { index: vi, kind: if vi < n { "data" } else { "parity" }, path: paths[vi].clone(), bytes });
433    }
434    let mut per_location: BTreeMap<PathBuf, usize> = BTreeMap::new();
435    for vi in 0..n + m {
436        *per_location.entry(placement(&opts.outputs, vi).to_path_buf()).or_insert(0) += 1;
437    }
438    let max_per_location = per_location.values().copied().max().unwrap_or(0);
439    Ok(SplitReport {
440        set_id: hex::encode(set_id),
441        descriptor_b3: hex::encode(desc_hash),
442        archive_b3: hex::encode(archive_b3),
443        archive_size,
444        piece_size,
445        piece_size_adaptive: opts.piece_size.is_none(),
446        data: opts.data,
447        parity: opts.parity,
448        pieces,
449        stripes,
450        volumes,
451        max_per_location,
452        placement_warning: max_per_location > m,
453    })
454}
455
456// ---------------------------------------------------------------- inspect
457
458/// One volume file as found on disk.
459#[derive(Clone, Debug, Serialize)]
460pub struct FoundVolume {
461    pub path: PathBuf,
462    pub index: usize,
463    pub kind: &'static str,
464    pub header_ok: bool,
465    pub descriptor_ok: bool,
466    /// Payload length matches the geometry.
467    pub size_ok: bool,
468    /// Indices (stripe positions) of pieces whose hash does not match; filled by verification.
469    pub bad_pieces: Vec<u32>,
470    pub verified: bool,
471}
472
473impl FoundVolume {
474    pub fn usable(&self) -> bool {
475        self.size_ok && self.bad_pieces.is_empty()
476    }
477}
478
479#[derive(Clone, Debug, Serialize)]
480pub struct SetStatus {
481    pub set_id: String,
482    /// BLAKE3 of the descriptor shared by every volume of the set.
483    pub descriptor_b3: String,
484    pub archive_name: String,
485    pub archive_size: u64,
486    pub archive_b3: String,
487    pub piece_size: u32,
488    pub data: u16,
489    pub parity: u16,
490    pub pieces: u32,
491    pub stripes: u32,
492    /// By volume index; `None` when the volume was not found.
493    pub volumes: Vec<Option<FoundVolume>>,
494    pub missing: Vec<usize>,
495    /// Stripes that cannot be rebuilt with the volumes at hand (after verification when requested).
496    pub stripes_short: u32,
497    pub reconstructible: bool,
498    /// Files that belong to the set but could not be placed (duplicate index, damaged at both ends).
499    pub extra: Vec<(PathBuf, String)>,
500    #[serde(skip)]
501    pub set: VolumeSet,
502}
503
504struct Probe {
505    path: PathBuf,
506    header: Option<VolumeHeader>,
507    trailer_index: Option<u16>,
508    set_id: Option<[u8; 32]>,
509    descriptor: Option<VolumeSet>,
510    file_len: u64,
511    error: Option<String>,
512}
513
514fn probe(path: &Path) -> Probe {
515    let mut p = Probe { path: path.to_path_buf(), header: None, trailer_index: None, set_id: None, descriptor: None, file_len: 0, error: None };
516    let mut f = match File::open(path) {
517        Ok(f) => f,
518        Err(e) => {
519            p.error = Some(e.to_string());
520            return p;
521        }
522    };
523    p.file_len = f.metadata().map(|m| m.len()).unwrap_or(0);
524    let mut hb = [0u8; HEADER_LEN];
525    if read_full(&mut f, &mut hb).map(|n| n == HEADER_LEN).unwrap_or(false) {
526        match decode_header(&hb) {
527            Ok(h) => {
528                p.set_id = Some(h.set_id);
529                p.header = Some(h);
530            }
531            // an intact header of another format version is not damage: the volume is refused,
532            // never read with the v1 rules through its trailer
533            Err(Error::Version(v)) => {
534                p.error = Some(format!("volume format version {v} is not supported by this build (version {VERSION})"));
535                return p;
536            }
537            Err(_) => {}
538        }
539    }
540    if p.file_len >= (HEADER_LEN + TRAILER_LEN) as u64 {
541        let mut tb = [0u8; TRAILER_LEN];
542        let ok = f.seek(SeekFrom::Start(p.file_len - TRAILER_LEN as u64)).is_ok() && read_full(&mut f, &mut tb).map(|n| n == TRAILER_LEN).unwrap_or(false);
543        if ok {
544            if let Ok((off, len, hash, index)) = decode_trailer(&tb) {
545                p.trailer_index = Some(index);
546                if len <= MAX_DESCRIPTOR && off.checked_add(len).is_some_and(|e| e + TRAILER_LEN as u64 == p.file_len) {
547                    let mut db = vec![0u8; len as usize];
548                    if f.seek(SeekFrom::Start(off)).is_ok() && read_full(&mut f, &mut db).map(|n| n as u64 == len).unwrap_or(false) && chunk::hash(&db) == hash {
549                        if let Ok(set) = format::cbor_decode::<VolumeSet>(&db) {
550                            if set.validate().is_ok() {
551                                let mut id = [0u8; 32];
552                                id.copy_from_slice(&set.set_id);
553                                // the hash-verified descriptor wins over a header that disagrees
554                                if p.header.is_some_and(|h| h.set_id != id) {
555                                    p.header = None;
556                                }
557                                p.set_id = Some(id);
558                                p.descriptor = Some(set);
559                            }
560                        }
561                    }
562                }
563            }
564        }
565    }
566    if p.header.is_none() && p.descriptor.is_none() {
567        p.error = Some("neither the header nor the trailer of this file is a valid T-saur volume".into());
568    }
569    p
570}
571
572/// Expand directories to the `.tsrv` files they contain (non-recursive), keep files as given.
573pub fn expand(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
574    let mut out = Vec::new();
575    for p in paths {
576        if p.is_dir() {
577            let mut found: Vec<PathBuf> = std::fs::read_dir(p)?.filter_map(|e| e.ok().map(|e| e.path())).filter(|f| f.is_file() && f.extension().is_some_and(|x| x == EXTENSION)).collect();
578            found.sort();
579            out.extend(found);
580        } else {
581            out.push(p.clone());
582        }
583    }
584    Ok(out)
585}
586
587/// Group the given files/directories into volume sets and, optionally, verify every piece.
588pub fn inspect(paths: &[PathBuf], verify: bool) -> Result<Vec<SetStatus>> {
589    let files = expand(paths)?;
590    let probes: Vec<Probe> = files.iter().map(|f| probe(f)).collect();
591    let mut groups: BTreeMap<[u8; 32], Vec<usize>> = BTreeMap::new();
592    let mut unknown: Vec<(PathBuf, String)> = Vec::new();
593    for (i, p) in probes.iter().enumerate() {
594        match p.set_id {
595            Some(id) => groups.entry(id).or_default().push(i),
596            None => unknown.push((p.path.clone(), p.error.clone().unwrap_or_else(|| "unrecognised".into()))),
597        }
598    }
599    let mut out = Vec::new();
600    for (id, members) in groups {
601        // one descriptor for the set: any member whose trailer verified
602        let descriptor_b3 = members.iter().find_map(|&i| probes[i].descriptor.as_ref().map(|d| hex::encode(chunk::hash(&format::cbor_encode(d).unwrap_or_default())))).unwrap_or_default();
603        let Some(set) = members.iter().find_map(|&i| probes[i].descriptor.clone()) else {
604            for &i in &members {
605                unknown.push((probes[i].path.clone(), "no member of this set has an intact descriptor".into()));
606            }
607            continue;
608        };
609        let mut volumes: Vec<Option<FoundVolume>> = vec![None; set.total()];
610        let mut extra = Vec::new();
611        for &i in &members {
612            let p = &probes[i];
613            let index = match (p.header.map(|h| h.index as usize), p.trailer_index.map(|t| t as usize)) {
614                (Some(h), Some(t)) if h != t => {
615                    extra.push((p.path.clone(), "header and trailer disagree on the volume index".into()));
616                    continue;
617                }
618                (Some(h), _) => h,
619                (None, Some(t)) => t,
620                (None, None) => {
621                    extra.push((p.path.clone(), "volume index unknown".into()));
622                    continue;
623                }
624            };
625            if index >= set.total() {
626                extra.push((p.path.clone(), format!("volume index {index} out of range")));
627                continue;
628            }
629            let header_ok = p
630                .header
631                .is_some_and(|h| h.data == set.data && h.parity == set.parity && h.piece_size == set.piece_size && h.archive_size == set.archive_size && h.payload_len == set.payload_len(index));
632            let expected_len = HEADER_LEN as u64 + set.payload_len(index);
633            let size_ok = p.file_len >= expected_len + TRAILER_LEN as u64;
634            let mut fv = FoundVolume {
635                path: p.path.clone(),
636                index,
637                kind: if set.is_parity(index) { "parity" } else { "data" },
638                header_ok,
639                descriptor_ok: p.descriptor.is_some(),
640                size_ok,
641                bad_pieces: Vec::new(),
642                verified: false,
643            };
644            if verify && size_ok {
645                fv.bad_pieces = verify_volume(&set, &p.path, index)?;
646                fv.verified = true;
647            }
648            match &volumes[index] {
649                Some(existing) if existing.usable() || !fv.usable() => extra.push((p.path.clone(), format!("duplicate of volume {}", index + 1))),
650                _ => volumes[index] = Some(fv),
651            }
652        }
653        let missing: Vec<usize> = (0..set.total()).filter(|&i| volumes[i].is_none()).collect();
654        let stripes_short = short_stripes(&set, &volumes);
655        out.push(SetStatus {
656            set_id: hex::encode(id),
657            descriptor_b3,
658            archive_name: set.archive_name.clone(),
659            archive_size: set.archive_size,
660            archive_b3: hex::encode(&set.archive_b3),
661            piece_size: set.piece_size,
662            data: set.data,
663            parity: set.parity,
664            pieces: set.pieces,
665            stripes: set.stripes,
666            volumes,
667            missing,
668            stripes_short,
669            reconstructible: stripes_short == 0,
670            extra,
671            set,
672        });
673    }
674    if out.is_empty() {
675        let detail = unknown.iter().map(|(p, e)| format!("{}: {e}", p.display())).collect::<Vec<_>>().join("; ");
676        return Err(Error::Missing(if detail.is_empty() { "no volume files found".into() } else { format!("no usable volume set among the inputs ({detail})") }));
677    }
678    // unrelated files are reported on every set (they belong to none)
679    for s in &mut out {
680        s.extra.extend(unknown.iter().cloned());
681    }
682    Ok(out)
683}
684
685/// Stripes that have fewer than `data` intact pieces (virtual pieces count as intact).
686fn short_stripes(set: &VolumeSet, volumes: &[Option<FoundVolume>]) -> u32 {
687    let n = set.data as usize;
688    let mut short = 0u32;
689    for s in 0..set.stripes {
690        let mut available = 0usize;
691        for (vi, vol) in volumes.iter().enumerate() {
692            let virtual_piece = !set.is_parity(vi) && (s as u64 * n as u64 + vi as u64) >= set.pieces as u64;
693            if virtual_piece {
694                available += 1;
695                continue;
696            }
697            if let Some(v) = vol {
698                if v.size_ok && !v.bad_pieces.contains(&s) {
699                    available += 1;
700                }
701            }
702        }
703        if available < n {
704            short += 1;
705        }
706    }
707    short
708}
709
710/// Hash-check every piece of one volume; returns the stripe positions that do not match.
711pub fn verify_volume(set: &VolumeSet, path: &Path, index: usize) -> Result<Vec<u32>> {
712    let ps = set.piece_size as usize;
713    let mut f = BufReader::with_capacity(ps.min(1 << 20), File::open(path)?);
714    f.seek(SeekFrom::Start(HEADER_LEN as u64))?;
715    let mut buf = vec![0u8; ps];
716    let mut bad = Vec::new();
717    let count = set.volume_pieces(index);
718    for s in 0..count {
719        let got = read_full(&mut f, &mut buf)?;
720        let expected = if set.is_parity(index) { set.parity_hash(s as u32, index - set.data as usize) } else { set.piece_hash((s * set.data as u64 + index as u64) as u32) };
721        if got != ps || chunk::hash(&buf) != expected {
722            bad.push(s as u32);
723        }
724    }
725    Ok(bad)
726}
727
728// ---------------------------------------------------------------- join / repair
729
730/// Open the usable volumes of a set for random access.
731fn open_volumes(status: &SetStatus) -> Result<Vec<Option<File>>> {
732    let mut files = Vec::with_capacity(status.set.total());
733    for v in &status.volumes {
734        files.push(match v {
735            Some(fv) if fv.size_ok => Some(File::open(&fv.path)?),
736            _ => None,
737        });
738    }
739    Ok(files)
740}
741
742/// Read stripe `s`: `data + parity` shards, `Some` only when present and hash-verified
743/// (virtual pieces beyond the archive end are `Some(zeros)`).
744fn read_stripe(set: &VolumeSet, files: &mut [Option<File>], s: u32, buf: &mut [Vec<u8>]) -> Result<Vec<Option<Vec<u8>>>> {
745    let (n, ps) = (set.data as usize, set.piece_size as usize);
746    let mut shards: Vec<Option<Vec<u8>>> = Vec::with_capacity(set.total());
747    for vi in 0..set.total() {
748        let virtual_piece = !set.is_parity(vi) && (s as u64 * n as u64 + vi as u64) >= set.pieces as u64;
749        if virtual_piece {
750            shards.push(Some(vec![0u8; ps]));
751            continue;
752        }
753        let expected = if set.is_parity(vi) { set.parity_hash(s, vi - n) } else { set.piece_hash((s as u64 * n as u64 + vi as u64) as u32) };
754        let shard = match &mut files[vi] {
755            Some(f) => {
756                let off = HEADER_LEN as u64 + s as u64 * ps as u64;
757                let b = &mut buf[vi];
758                let ok = f.seek(SeekFrom::Start(off)).is_ok() && read_full(f, b).map(|g| g == ps).unwrap_or(false) && chunk::hash(b) == expected;
759                if ok {
760                    Some(b.clone())
761                } else {
762                    None
763                }
764            }
765            None => None,
766        };
767        shards.push(shard);
768    }
769    Ok(shards)
770}
771
772/// Rebuild the missing shards of a stripe in place (only the data shards when `data_only`);
773/// error when too few are available. Returns the number of pieces rebuilt.
774fn rebuild_stripe(set: &VolumeSet, rs: &Option<ReedSolomon>, s: u32, shards: &mut [Option<Vec<u8>>], data_only: bool) -> Result<u32> {
775    let n = set.data as usize;
776    let available = shards.iter().filter(|x| x.is_some()).count();
777    if available < n {
778        let missing: Vec<String> = shards.iter().enumerate().filter(|(_, x)| x.is_none()).map(|(i, _)| set.volume_name(i)).collect();
779        return Err(Error::Missing(format!("stripe {s}: only {available} of the {n} pieces needed are intact; unavailable: {}", missing.join(", "))));
780    }
781    let wanted = if data_only { &shards[..n] } else { &shards[..] };
782    let rebuilt = wanted.iter().filter(|x| x.is_none()).count() as u32;
783    if rebuilt > 0 {
784        let rs = rs.as_ref().ok_or_else(|| Error::Missing(format!("stripe {s}: a piece is missing and the set has no parity")))?;
785        let res = if data_only { rs.reconstruct_data(shards) } else { rs.reconstruct(shards) };
786        res.map_err(|e| Error::Corrupt(format!("reed-solomon reconstruct: {e:?}")))?;
787    }
788    Ok(rebuilt)
789}
790
791#[derive(Clone, Debug, Serialize)]
792pub struct JoinReport {
793    pub set_id: String,
794    pub archive_name: String,
795    pub archive_size: u64,
796    pub output: PathBuf,
797    pub volumes_used: Vec<usize>,
798    pub volumes_missing: Vec<usize>,
799    pub pieces_rebuilt: u32,
800    pub archive_b3: String,
801    pub archive_hash_ok: bool,
802}
803
804/// Rebuild the archive from the given volumes (files or directories) into `out`.
805/// The result is written to a temporary name and renamed only after its hash matches the
806/// descriptor; nothing is left behind on failure.
807pub fn join(paths: &[PathBuf], out: &Path) -> Result<JoinReport> {
808    let mut sets = inspect(paths, false)?;
809    if sets.len() > 1 {
810        return Err(Error::Invalid(format!("the inputs hold {} different volume sets: give the volumes of one set", sets.len())));
811    }
812    let status = sets.remove(0);
813    let set = status.set.clone();
814    let mut files = open_volumes(&status)?;
815    let rs = rs_new(set.data as usize, set.parity as usize)?;
816    let ps = set.piece_size as usize;
817    let mut buf: Vec<Vec<u8>> = (0..set.total()).map(|_| vec![0u8; ps]).collect();
818    let tmp = out.with_extension(format!("{}.tsr-partial", out.extension().map(|x| x.to_string_lossy().to_string()).unwrap_or_default()));
819    let result = (|| -> Result<u32> {
820        let mut w = BufWriter::with_capacity(1 << 20, File::create(&tmp)?);
821        let mut hasher = blake3::Hasher::new();
822        let mut written = 0u64;
823        let mut rebuilt = 0u32;
824        for s in 0..set.stripes {
825            let mut shards = read_stripe(&set, &mut files, s, &mut buf)?;
826            rebuilt += rebuild_stripe(&set, &rs, s, &mut shards, true)?;
827            for (v, shard) in shards.iter().take(set.data as usize).enumerate() {
828                let pi = s as u64 * set.data as u64 + v as u64;
829                if pi >= set.pieces as u64 {
830                    break;
831                }
832                let piece = shard.as_ref().ok_or_else(|| Error::Corrupt("reed-solomon left a shard empty".into()))?;
833                let take = (set.archive_size - written).min(ps as u64) as usize;
834                w.write_all(&piece[..take])?;
835                hasher.update(&piece[..take]);
836                written += take as u64;
837            }
838        }
839        w.flush()?;
840        if written != set.archive_size || hasher.finalize().as_bytes()[..] != set.archive_b3[..] {
841            return Err(Error::HashMismatch("rebuilt archive does not match the hash recorded at split time".into()));
842        }
843        Ok(rebuilt)
844    })();
845    match result {
846        Ok(rebuilt) => {
847            if out.exists() {
848                std::fs::remove_file(out)?;
849            }
850            std::fs::rename(&tmp, out)?;
851            Ok(JoinReport {
852                set_id: status.set_id.clone(),
853                archive_name: set.archive_name.clone(),
854                archive_size: set.archive_size,
855                output: out.to_path_buf(),
856                volumes_used: (0..set.total()).filter(|&i| files[i].is_some()).collect(),
857                volumes_missing: status.missing.clone(),
858                pieces_rebuilt: rebuilt,
859                archive_b3: hex::encode(&set.archive_b3),
860                archive_hash_ok: true,
861            })
862        }
863        Err(e) => {
864            let _ = std::fs::remove_file(&tmp);
865            Err(e)
866        }
867    }
868}
869
870#[derive(Clone, Debug, Serialize)]
871pub struct RepairReport {
872    pub set_id: String,
873    pub rebuilt: Vec<VolumeInfo>,
874    pub pieces_rebuilt: u32,
875}
876
877/// Recreate every missing or damaged volume of the set from the intact ones. New files go to
878/// `out_dir` when given, otherwise next to the first intact volume; damaged files are replaced
879/// in place. Recreated volumes are byte-identical to the originals.
880pub fn repair(paths: &[PathBuf], out_dir: Option<&Path>) -> Result<RepairReport> {
881    let mut sets = inspect(paths, true)?;
882    if sets.len() > 1 {
883        return Err(Error::Invalid(format!("the inputs hold {} different volume sets: give the volumes of one set", sets.len())));
884    }
885    let status = sets.remove(0);
886    let set = status.set.clone();
887    let targets: Vec<(usize, PathBuf)> = (0..set.total())
888        .filter_map(|vi| match &status.volumes[vi] {
889            Some(fv) if fv.usable() && fv.header_ok && fv.descriptor_ok => None,
890            Some(fv) => Some((vi, fv.path.clone())),
891            None => {
892                let dir = out_dir.map(Path::to_path_buf).or_else(|| status.volumes.iter().flatten().next().map(|f| f.path.parent().map(Path::to_path_buf).unwrap_or_default()));
893                dir.map(|d| (vi, d.join(set.volume_name(vi))))
894            }
895        })
896        .collect();
897    if targets.is_empty() {
898        return Ok(RepairReport { set_id: status.set_id.clone(), rebuilt: Vec::new(), pieces_rebuilt: 0 });
899    }
900    if status.stripes_short > 0 {
901        return Err(Error::Missing(format!("{} stripe(s) cannot be rebuilt with the volumes at hand", status.stripes_short)));
902    }
903    let mut files = open_volumes(&status)?;
904    // damaged volumes are read for their intact pieces but never trusted blindly (hashes decide)
905    let rs = rs_new(set.data as usize, set.parity as usize)?;
906    let ps = set.piece_size as usize;
907    let mut buf: Vec<Vec<u8>> = (0..set.total()).map(|_| vec![0u8; ps]).collect();
908    let desc = format::cbor_encode(&set)?;
909    let desc_hash = chunk::hash(&desc);
910    let mut set_id = [0u8; 32];
911    set_id.copy_from_slice(&set.set_id);
912    let tmp_paths: Vec<PathBuf> = targets.iter().map(|(_, p)| p.with_extension(format!("{EXTENSION}.tsr-partial"))).collect();
913    let result = (|| -> Result<u32> {
914        let mut writers: Vec<BufWriter<File>> = Vec::with_capacity(targets.len());
915        for ((vi, _), tmp) in targets.iter().zip(tmp_paths.iter()) {
916            let mut w = BufWriter::with_capacity(1 << 20, File::create(tmp)?);
917            let header = VolumeHeader { index: *vi as u16, data: set.data, parity: set.parity, piece_size: set.piece_size, archive_size: set.archive_size, set_id, payload_len: set.payload_len(*vi) };
918            w.write_all(&encode_header(&header))?;
919            writers.push(w);
920        }
921        let mut rebuilt = 0u32;
922        for s in 0..set.stripes {
923            let mut shards = read_stripe(&set, &mut files, s, &mut buf)?;
924            rebuilt += rebuild_stripe(&set, &rs, s, &mut shards, false)?;
925            for ((vi, _), w) in targets.iter().zip(writers.iter_mut()) {
926                if s as u64 >= set.volume_pieces(*vi) {
927                    continue;
928                }
929                let piece = shards[*vi].as_ref().ok_or_else(|| Error::Corrupt("reed-solomon left a shard empty".into()))?;
930                w.write_all(piece)?;
931            }
932        }
933        for ((vi, _), mut w) in targets.iter().zip(writers) {
934            w.write_all(&desc)?;
935            w.write_all(&encode_trailer(HEADER_LEN as u64 + set.payload_len(*vi), desc.len() as u64, &desc_hash, *vi as u16))?;
936            w.flush()?;
937        }
938        Ok(rebuilt)
939    })();
940    match result {
941        Ok(pieces_rebuilt) => {
942            drop(files);
943            let mut rebuilt = Vec::new();
944            for ((vi, path), tmp) in targets.iter().zip(tmp_paths.iter()) {
945                if path.exists() {
946                    std::fs::remove_file(path)?;
947                }
948                std::fs::rename(tmp, path)?;
949                let bytes = std::fs::metadata(path)?.len();
950                rebuilt.push(VolumeInfo { index: *vi, kind: if set.is_parity(*vi) { "parity" } else { "data" }, path: path.clone(), bytes });
951            }
952            Ok(RepairReport { set_id: status.set_id.clone(), rebuilt, pieces_rebuilt })
953        }
954        Err(e) => {
955            for tmp in &tmp_paths {
956                let _ = std::fs::remove_file(tmp);
957            }
958            Err(e)
959        }
960    }
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966
967    #[test]
968    fn adaptive_piece_size_boundaries() {
969        // tiny and empty archives: the floor
970        assert_eq!(adaptive_piece_size(0, 4), MIN_PIECE_SIZE);
971        assert_eq!(adaptive_piece_size(1, 1), MIN_PIECE_SIZE);
972        // the benchmark archive with 4 data volumes: 16 pieces per volume would be ~15 KiB, floor wins
973        assert_eq!(adaptive_piece_size(956_731, 4), 64 << 10);
974        // exactly 16 pieces of 64 KiB per volume: stays at 64 KiB; one byte more: 128 KiB
975        assert_eq!(adaptive_piece_size(4 * 16 * 65_536, 4), 64 << 10);
976        assert_eq!(adaptive_piece_size(4 * 16 * 65_536 + 1, 4), 128 << 10);
977        // powers of two only
978        assert_eq!(adaptive_piece_size(4 * 16 * 200_000, 4), 256 << 10);
979        // the 1 MiB ceiling for ordinary sizes (64 MiB and 10 GiB with 4 data volumes)
980        assert_eq!(adaptive_piece_size(64 << 20, 4), 1 << 20);
981        assert_eq!(adaptive_piece_size(10 << 30, 4), 1 << 20);
982        // beyond a million pieces the size grows, never above the format maximum
983        assert_eq!(adaptive_piece_size(1_500_000u64 << 20, 4), 2 << 20);
984        assert_eq!(adaptive_piece_size(u64::MAX / 4, 4), MAX_PIECE_SIZE);
985        // fewer data volumes need bigger pieces for the same archive
986        assert!(adaptive_piece_size(64 << 20, 1) >= adaptive_piece_size(64 << 20, 8));
987    }
988}