Skip to main content

decdn_cli/commands/
bundle_manifest.rs

1//! The local, advisory `.decdn-manifest.json` skip-cache `decdn bundle pull`
2//! writes into an output directory. It indexes every file the bundle(s) have
3//! placed there — keyed by bundle-relative POSIX path, merged across runs — so a
4//! later pull skips unchanged files without re-hashing and splices unchanged
5//! byte ranges from bytes already on disk.
6//!
7//! The cache is advisory, not authoritative. A skip takes one of two paths: the
8//! re-hash path confirms the on-disk bytes against the new manifest hash before
9//! skipping; the fast path skips without re-hashing when the saved record's hash
10//! agrees and the file's size and mtime are unchanged since that record was
11//! written. A missing, corrupt, unreadable, or superseded record costs only a
12//! re-hash, and `--overwrite` (or deleting the file) forces a full re-fetch. The
13//! one residual trust is the fast path: a file edited in place without changing
14//! its size or mtime is not re-hashed. Every spliced donor chunk is always
15//! re-hashed before its bytes are used, so donor reuse never risks correctness.
16
17use std::collections::BTreeMap;
18use std::path::Path;
19use std::time::UNIX_EPOCH;
20
21use anyhow::Context as _;
22use serde::{Deserialize, Serialize};
23
24/// File name of the local skip-cache, written at the pull output root.
25pub(crate) const SAVED_MANIFEST_NAME: &str = ".decdn-manifest.json";
26
27/// Current on-disk schema version. A file with any other version loads empty.
28const SAVED_MANIFEST_VERSION: u32 = 1;
29
30/// The local skip-cache: an index of the bundle files known to live under one
31/// output root, keyed by bundle-relative POSIX path. Ordered (`BTreeMap`) so the
32/// serialized form is deterministic across runs.
33#[derive(Debug, Default, Clone, Serialize, Deserialize)]
34pub(crate) struct SavedManifest {
35    /// On-disk schema version; see [`SAVED_MANIFEST_VERSION`].
36    version: u32,
37    /// Recorded files, keyed by bundle-relative POSIX path.
38    files: BTreeMap<String, SavedFile>,
39}
40
41impl SavedManifest {
42    /// The recorded state for `path`, if any.
43    pub(crate) fn get(&self, path: &str) -> Option<&SavedFile> {
44        self.files.get(path)
45    }
46
47    /// Every recorded file as `(path, record)`, in sorted path order. Lets the
48    /// pull pre-pass index the whole root's content, not just one bundle's paths.
49    pub(crate) fn records(&self) -> impl Iterator<Item = (&str, &SavedFile)> {
50        self.files.iter().map(|(p, r)| (p.as_str(), r))
51    }
52}
53
54/// One recorded file: the whole-file content address and size last materialized
55/// at this path, the mtime observed for it (the cheap change gate), and its
56/// range-dedup chunks when the source manifest carried them.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub(crate) struct SavedFile {
59    /// The whole-file BLAKE3 content address, `b3:<hex>`.
60    pub(crate) hash: String,
61    /// The file's byte length.
62    pub(crate) size: u64,
63    /// The mtime observed for the materialized file.
64    pub(crate) mtime: SavedMtime,
65    /// Ordered `{hash, size}` range-dedup chunks, when the source manifest
66    /// carried them; `None` for a plain (unhinted) entry.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub(crate) chunks: Option<Vec<SavedChunk>>,
69}
70
71/// One range-dedup chunk of a [`SavedFile`]: a BLAKE3 over a byte range and its
72/// length. Chunk sizes sum to the file's `size`; offsets are the running sum.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub(crate) struct SavedChunk {
75    /// The chunk's BLAKE3 content address, `b3:<hex>`.
76    pub(crate) hash: String,
77    /// The chunk's length in bytes.
78    pub(crate) size: u64,
79}
80
81/// A file's modification time as whole seconds and sub-second nanos since the
82/// Unix epoch — the cheap gate the skip-cache compares before a re-hash.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84pub(crate) struct SavedMtime {
85    /// Whole seconds since the Unix epoch.
86    pub(crate) secs: u64,
87    /// Sub-second nanoseconds.
88    pub(crate) nanos: u32,
89}
90
91impl SavedMtime {
92    /// The mtime from `meta`, or `None` when it is unavailable or before the
93    /// Unix epoch (either forces a re-hash rather than a false fast-skip).
94    pub(crate) fn of(meta: &std::fs::Metadata) -> Option<SavedMtime> {
95        let d = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
96        Some(SavedMtime {
97            secs: d.as_secs(),
98            nanos: d.subsec_nanos(),
99        })
100    }
101}
102
103/// Place a saved file's chunks at absolute offsets: `(hash, offset, len)` in
104/// content order. `None` when the file has no chunks or the sizes do not sum to
105/// its whole-file `size` (a malformed record — ignored, never fatal).
106pub(crate) fn saved_hints(rec: &SavedFile) -> Option<Vec<(String, u64, u64)>> {
107    let chunks = rec.chunks.as_ref()?;
108    let mut out = Vec::with_capacity(chunks.len());
109    let mut offset = 0u64;
110    for c in chunks {
111        out.push((c.hash.clone(), offset, c.size));
112        offset = offset.checked_add(c.size)?;
113    }
114    (offset == rec.size).then_some(out)
115}
116
117/// Load the skip-cache at `out_root`. A missing, unreadable, malformed, or
118/// wrong-version file yields an empty manifest — the cache is advisory, so its
119/// absence only means every path falls to the re-hash gate.
120pub(crate) fn load(out_root: &Path) -> SavedManifest {
121    let path = out_root.join(SAVED_MANIFEST_NAME);
122    let Ok(bytes) = std::fs::read(&path) else {
123        return SavedManifest::default();
124    };
125    match serde_json::from_slice::<SavedManifest>(&bytes) {
126        Ok(m) if m.version == SAVED_MANIFEST_VERSION => m,
127        _ => SavedManifest::default(),
128    }
129}
130
131/// Overlay `updates` onto `manifest` in place: insert-or-replace per path, keep
132/// untouched paths, and stamp the current schema version. Pairs with
133/// [`serialize`] + [`write_bytes`] so a long pull can fold each batch of landed
134/// files into one in-memory manifest and flush it repeatedly.
135pub(crate) fn merge(manifest: &mut SavedManifest, updates: BTreeMap<String, SavedFile>) {
136    manifest.version = SAVED_MANIFEST_VERSION;
137    for (path, rec) in updates {
138        manifest.files.insert(path, rec);
139    }
140}
141
142/// Serialize `manifest` to the on-disk JSON bytes. Split from [`write_bytes`] so
143/// the (fast, in-memory) serialize runs on the async caller and the blocking
144/// filesystem write can be handed to a blocking task with the owned bytes.
145pub(crate) fn serialize(manifest: &SavedManifest) -> anyhow::Result<Vec<u8>> {
146    serde_json::to_vec_pretty(manifest).context("serialize saved manifest")
147}
148
149/// Write pre-serialized skip-cache `bytes` atomically to `out_root`'s cache file
150/// (temp file in the same directory, `sync_all`, then rename). Blocking I/O: call
151/// it from a blocking context. Owning `bytes` (not a borrowed manifest) is what
152/// lets an incremental flush move the write off the async executor.
153pub(crate) fn write_bytes(out_root: &Path, bytes: &[u8]) -> anyhow::Result<()> {
154    let dest = out_root.join(SAVED_MANIFEST_NAME);
155    let mut tmp = super::fetch::temp_in_parent(&dest).context("stage saved manifest")?;
156    std::io::Write::write_all(tmp.as_file_mut(), bytes).context("write saved manifest")?;
157    tmp.as_file().sync_all().context("sync saved manifest")?;
158    tmp.persist(&dest)
159        .map_err(|e| e.error)
160        .context("persist saved manifest")?;
161    Ok(())
162}
163
164/// Overlay `updates` onto `prior` (insert-or-replace per path, untouched paths
165/// kept) and write the result atomically to `out_root`'s skip-cache — the
166/// one-shot convenience over [`merge`] + [`serialize`] + [`write_bytes`]. A test
167/// helper: the pull path folds and flushes incrementally through the granular
168/// functions instead.
169#[cfg(test)]
170pub(crate) fn merge_and_write(
171    out_root: &Path,
172    mut prior: SavedManifest,
173    updates: BTreeMap<String, SavedFile>,
174) -> anyhow::Result<()> {
175    merge(&mut prior, updates);
176    let bytes = serialize(&prior)?;
177    write_bytes(out_root, &bytes)
178}
179
180#[cfg(test)]
181#[allow(
182    clippy::unwrap_used,
183    clippy::expect_used,
184    clippy::indexing_slicing,
185    clippy::panic
186)]
187mod tests;