Skip to main content

dbmd_core/
assets.rs

1//! `assets` — the db.md asset layer.
2//!
3//! Raw binary assets (PDFs, recordings, large exports) belong to a store but
4//! are too heavy for Git. A content file (the **wrapper**) declares one via an
5//! `asset:` / `assets:` frontmatter key; this module records each in the
6//! root-level `assets.jsonl` manifest: store-relative path, SHA-256, size,
7//! media type, the declaring wrapper(s), and whether it is required for
8//! byte-completeness.
9//!
10//! The manifest is a **pure projection** of (wrappers + asset files on disk):
11//! every field is derivable, so a [`scan`] where the bytes are present
12//! reproduces it byte-for-byte, exactly like `index.jsonl`. db.md never
13//! transports the bytes and never names a storage provider; that is the
14//! hosting/transport layer's job, keyed off the SHA-256. This module never
15//! shells out to git and never touches the network.
16//!
17//! Four operations — one write, three reads:
18//!   - [`scan`]   (write) discover declared assets, hash present files, rewrite the manifest
19//!   - [`verify`] (read)  prove the local store is byte-complete for required assets
20//!   - [`status`] (read)  report present / missing without failing
21//!   - [`paths`]  (read)  the store-relative path list (for an ignore mechanism)
22//!
23//! Path safety: every declared path is validated store-relative (no `..`, no
24//! absolute, no escape) via [`crate::store::ensure_path_within_store`] wherever
25//! a path is read or resolved, so a poisoned manifest can never make `scan`
26//! hash, or a restore write, outside the store.
27
28use std::collections::{BTreeMap, BTreeSet};
29use std::fmt::Write as _;
30use std::io::Read as _;
31use std::path::{Component, Path, PathBuf};
32
33use serde::{Deserialize, Serialize};
34use serde_norway::Value;
35use sha2::{Digest, Sha256};
36
37use crate::parser;
38use crate::store::Store;
39
40/// The manifest file name at the store root.
41pub const MANIFEST_FILE: &str = "assets.jsonl";
42
43/// One asset record — one line of `assets.jsonl`.
44///
45/// Every field is derivable from the store (wrapper frontmatter + the file on
46/// disk), so the manifest rebuilds byte-for-byte. Field declaration order is
47/// the canonical JSON key order; `wrappers` is always a sorted list (never a
48/// bare string) so serialization is deterministic.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct AssetRecord {
51    /// Store-relative path of the raw bytes, forward-slash, with extension. The
52    /// record key. May differ from `wrappers` (the wrapper is the `.md`).
53    pub path: String,
54    /// Lowercase-hex SHA-256 of the bytes: the integrity check and the provider
55    /// blob key. May repeat across records (identical bytes at two paths).
56    pub sha256: String,
57    /// Size in bytes.
58    pub bytes: u64,
59    /// Best-effort MIME type derived from the path extension.
60    pub media_type: String,
61    /// Store-relative path(s) of the content file(s) that declare this asset,
62    /// sorted ascending. Usually one.
63    pub wrappers: Vec<String>,
64    /// Whether the asset is required for byte-completeness (default `true`;
65    /// `false` only when every declaration marks it optional).
66    pub required: bool,
67}
68
69/// A single `asset:` / `assets:` declaration read from a wrapper's frontmatter.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Declaration {
72    /// The raw store-relative path string as written in frontmatter.
73    pub path: String,
74    /// Whether this declaration marks the asset required (bare string and
75    /// object-without-`required` default to `true`).
76    pub required: bool,
77}
78
79// ─────────────────────────────────────────────────────────────────────────────
80// Reports (serialized directly in `--json`; the CLI renders the text form)
81// ─────────────────────────────────────────────────────────────────────────────
82
83/// Result of [`scan`].
84#[derive(Debug, Serialize)]
85pub struct ScanReport {
86    pub manifest: String,
87    pub cataloged: usize,
88    pub hashed: usize,
89    pub preserved: usize,
90    pub bytes: u64,
91    pub wrote: bool,
92    pub dry_run: bool,
93    pub warnings: Vec<String>,
94    pub untracked: Vec<String>,
95}
96
97/// One asset's local state, used by [`status`] and [`verify`].
98#[derive(Debug, Serialize)]
99pub struct AssetState {
100    pub path: String,
101    pub sha256: String,
102    pub bytes: u64,
103    pub required: bool,
104    /// `present` / `missing` (status); `ok` / `missing` / `corrupt` (verify).
105    pub state: String,
106}
107
108/// Result of [`status`].
109#[derive(Debug, Serialize)]
110pub struct StatusReport {
111    pub total: usize,
112    pub present: usize,
113    pub missing: usize,
114    pub required_missing: usize,
115    pub optional_missing: usize,
116    pub bytes_total: u64,
117    pub bytes_missing: u64,
118    pub assets: Vec<AssetState>,
119}
120
121/// Result of [`verify`].
122#[derive(Debug, Serialize)]
123pub struct VerifyReport {
124    pub mode: String,
125    pub checked: usize,
126    pub ok: usize,
127    pub missing: Vec<String>,
128    pub corrupt: Vec<String>,
129    pub complete: bool,
130}
131
132// ─────────────────────────────────────────────────────────────────────────────
133// Manifest read / write
134// ─────────────────────────────────────────────────────────────────────────────
135
136/// Read `assets.jsonl` into records, deduped by path (last line wins) and
137/// sorted by path ascending. A missing manifest is an empty store, not an
138/// error. A malformed line is an `InvalidData` error (the CLI surfaces it;
139/// [`crate::validate`] flags it leniently as `ASSET_MANIFEST_MALFORMED`).
140pub fn read_manifest(store: &Store) -> crate::Result<Vec<AssetRecord>> {
141    let text = match store
142        .read_text_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES)
143    {
144        Ok(text) => text,
145        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
146        Err(error) => return Err(error.into()),
147    };
148    let mut by_path: BTreeMap<String, AssetRecord> = BTreeMap::new();
149    for (i, line) in text.lines().enumerate() {
150        if line.trim().is_empty() {
151            continue;
152        }
153        let rec: AssetRecord = serde_json::from_str(line).map_err(|e| {
154            std::io::Error::new(
155                std::io::ErrorKind::InvalidData,
156                format!("{MANIFEST_FILE} line {}: {e}", i + 1),
157            )
158        })?;
159        by_path.insert(rec.path.clone(), rec);
160    }
161    Ok(by_path.into_values().collect())
162}
163
164/// The canonical serialized form of a record set: one JSON line per record,
165/// records sorted by path ascending, trailing newline. An empty record set is
166/// the empty string (the manifest file is removed, not written empty). This is
167/// the SINGLE source of the manifest's byte layout — both [`write_manifest`] and
168/// the [`scan`] no-change gate go through it, so "what scan would write" and
169/// "what's on disk" are compared as the same bytes.
170fn serialize_manifest(records: &[AssetRecord]) -> String {
171    if records.is_empty() {
172        return String::new();
173    }
174    let mut sorted = records.to_vec();
175    sorted.sort_by(|a, b| a.path.cmp(&b.path));
176    let mut out = String::new();
177    for rec in &sorted {
178        let line = serde_json::to_string(rec).expect("AssetRecord serializes");
179        out.push_str(&line);
180        out.push('\n');
181    }
182    out
183}
184
185/// Write the manifest atomically (temp + fsync + rename through the store's
186/// held root capability), records sorted by path ascending. An empty record set
187/// removes the file.
188pub fn write_manifest(store: &Store, records: &[AssetRecord]) -> crate::Result<()> {
189    let abs = Path::new(MANIFEST_FILE);
190    let out = serialize_manifest(records);
191    if out.is_empty() {
192        match store.remove_file(abs) {
193            Ok(()) => {}
194            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
195            Err(error) => return Err(error.into()),
196        }
197        return Ok(());
198    }
199    store.write_atomic(abs, out.as_bytes())?;
200    Ok(())
201}
202
203// ─────────────────────────────────────────────────────────────────────────────
204// scan (write) — rebuild the manifest from wrapper declarations
205// ─────────────────────────────────────────────────────────────────────────────
206
207/// Walk every content file, read its `asset`/`assets` declarations, hash the
208/// present files, and (re)write the manifest. The manifest is a projection: a
209/// path no longer declared by any wrapper drops out. Bytes absent locally but
210/// previously cataloged are preserved (the eviction / disk-relief case) since
211/// they cannot be re-hashed. `dry_run` computes without writing; `untracked`
212/// additionally reports non-markdown files under `sources/` that no wrapper
213/// declares. Never writes when nothing changed (keeps the Git diff and the
214/// `--dry-run`-then-scan idempotent).
215pub fn scan(store: &Store, dry_run: bool, untracked: bool) -> crate::Result<ScanReport> {
216    // Tolerate a malformed existing manifest here: scan rebuilds from the files,
217    // so a corrupt prior file is simply replaced. We still read it (best effort)
218    // to preserve hashes for evicted (absent-but-cataloged) assets.
219    let existing_by_path: BTreeMap<String, AssetRecord> = read_manifest(store)
220        .unwrap_or_default()
221        .into_iter()
222        .map(|r| (r.path.clone(), r))
223        .collect();
224
225    // Aggregate declarations across all content files: path -> (wrappers, required).
226    let mut wrappers_by_path: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
227    let mut required_by_path: BTreeMap<String, bool> = BTreeMap::new();
228    let mut declared_paths: BTreeSet<String> = BTreeSet::new();
229    let mut warnings: Vec<String> = Vec::new();
230
231    for rel in store.walk()? {
232        let text = match store.read_text_bounded(&rel, parser::MAX_DBMD_FILE_BYTES) {
233            Ok(text) => text,
234            Err(_) => continue,
235        };
236        let parsed = match parser::split_frontmatter(&text, &rel) {
237            Ok(parsed) => parsed,
238            Err(_) => continue,
239        };
240        let fm = match parser::Frontmatter::parse(&parsed.frontmatter_yaml, &rel) {
241            Ok(frontmatter) => frontmatter,
242            Err(_) => continue, // unparseable / not a content file: skip
243        };
244        let wrapper = rel_to_string(&rel);
245        for decl in declared_assets(&fm) {
246            let norm = match normalize_asset_path(&decl.path) {
247                Ok(n) => n,
248                Err(e) => {
249                    warnings.push(format!("{wrapper}: {e}"));
250                    continue;
251                }
252            };
253            if is_markdown(&norm) {
254                warnings.push(format!(
255                    "{wrapper}: asset path points at a markdown content file ({norm}); skipped"
256                ));
257                continue;
258            }
259            wrappers_by_path
260                .entry(norm.clone())
261                .or_default()
262                .insert(wrapper.clone());
263            let req = required_by_path.entry(norm.clone()).or_insert(false);
264            *req = *req || decl.required;
265            declared_paths.insert(norm);
266        }
267    }
268
269    // Build records.
270    let mut records: Vec<AssetRecord> = Vec::new();
271    let mut hashed = 0usize;
272    let mut preserved = 0usize;
273    for (path, wrappers) in &wrappers_by_path {
274        let required = *required_by_path.get(path).unwrap_or(&true);
275        let wrappers: Vec<String> = wrappers.iter().cloned().collect();
276
277        // Belt-and-suspenders containment check before any disk read.
278        let abs = match store.capability_relative(Path::new(path)) {
279            Ok(p) => p,
280            Err(_) => {
281                warnings.push(format!("{path}: escapes the store root; skipped"));
282                continue;
283            }
284        };
285
286        match store.open_regular(abs) {
287            Ok(file) => {
288                let (sha256, bytes) = sha256_file(file)?;
289                records.push(AssetRecord {
290                    path: path.clone(),
291                    sha256,
292                    bytes,
293                    media_type: media_type_for(path),
294                    wrappers,
295                    required,
296                });
297                hashed += 1;
298            }
299            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
300                if let Some(prev) = existing_by_path.get(path) {
301                    // Evicted: bytes gone locally but previously cataloged. Preserve the
302                    // committed hash/size (we cannot re-hash what is not here).
303                    records.push(AssetRecord {
304                        path: path.clone(),
305                        sha256: prev.sha256.clone(),
306                        bytes: prev.bytes,
307                        media_type: media_type_for(path),
308                        wrappers,
309                        required,
310                    });
311                    preserved += 1;
312                } else {
313                    warnings.push(format!(
314                        "{path}: declared but absent and never cataloged; cannot hash (skipped)"
315                    ));
316                }
317            }
318            Err(error) => {
319                warnings.push(format!(
320                    "{path}: is not a readable regular in-store file: {error}"
321                ));
322            }
323        }
324    }
325    records.sort_by(|a, b| a.path.cmp(&b.path));
326
327    // Saturating: poisoned-manifest `bytes` can overflow a plain `.sum()` (debug
328    // abort / release wrap); see `status`.
329    let bytes: u64 = records.iter().fold(0u64, |a, r| a.saturating_add(r.bytes));
330    let cataloged = records.len();
331
332    let untracked_list = if untracked {
333        find_untracked(store, &declared_paths)?
334    } else {
335        Vec::new()
336    };
337
338    // Only write when the canonical BYTES differ from what's on disk. Comparing
339    // parsed records would miss non-canonical on-disk state — duplicate lines
340    // from a git `merge=union`, a wrong sort, a missing trailing newline — since
341    // `read_manifest` dedupes-by-path and sorts, so a poisoned file parses back
342    // equal to the freshly computed records and the no-op gate never repairs it.
343    // We instead compare the canonical serialization against the raw on-disk
344    // bytes, so `scan` recompacts a non-canonical manifest (mirroring how
345    // `index::rebuild_all` always normalizes its artifacts). This is also the
346    // documented `merge=union` recovery (SPEC § Assets).
347    let mut wrote = false;
348    if !dry_run {
349        let canonical = serialize_manifest(&records);
350        let on_disk = match store
351            .read_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES)
352        {
353            Ok(bytes) => bytes,
354            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
355            Err(error) => return Err(error.into()),
356        };
357        if on_disk != canonical.as_bytes() {
358            write_manifest(store, &records)?;
359            wrote = true;
360        }
361    }
362
363    Ok(ScanReport {
364        manifest: MANIFEST_FILE.to_string(),
365        cataloged,
366        hashed,
367        preserved,
368        bytes,
369        wrote,
370        dry_run,
371        warnings,
372        untracked: untracked_list,
373    })
374}
375
376// ─────────────────────────────────────────────────────────────────────────────
377// verify (read) — byte-completeness gate
378// ─────────────────────────────────────────────────────────────────────────────
379
380/// Check that every required asset (plus optional, under `include_optional`) is
381/// present locally and matches the manifest. `quick` = presence + size only
382/// (fast); otherwise a full SHA-256 re-hash. This is a SWEEP (O(asset bytes) in
383/// deep mode), never a loop op. `complete` is true iff nothing is missing or
384/// corrupt in the considered set.
385pub fn verify(store: &Store, include_optional: bool, quick: bool) -> crate::Result<VerifyReport> {
386    let records = read_manifest(store)?;
387    let mut missing = Vec::new();
388    let mut corrupt = Vec::new();
389    let mut checked = 0usize;
390
391    for rec in &records {
392        if !rec.required && !include_optional {
393            continue;
394        }
395        checked += 1;
396        let abs = match store.capability_relative(Path::new(&rec.path)) {
397            Ok(p) => p,
398            Err(_) => {
399                // A manifest path that escapes the store is not restorable here.
400                corrupt.push(rec.path.clone());
401                continue;
402            }
403        };
404        let file = match store.open_regular(abs) {
405            Ok(file) => file,
406            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
407                missing.push(rec.path.clone());
408                continue;
409            }
410            Err(_) => {
411                corrupt.push(rec.path.clone());
412                continue;
413            }
414        };
415        if quick {
416            let len = file.metadata()?.len();
417            if len != rec.bytes {
418                corrupt.push(rec.path.clone());
419            }
420        } else {
421            let (sha, bytes) = sha256_file(file)?;
422            if sha != rec.sha256 || bytes != rec.bytes {
423                corrupt.push(rec.path.clone());
424            }
425        }
426    }
427
428    let ok = checked - missing.len() - corrupt.len();
429    let complete = missing.is_empty() && corrupt.is_empty();
430    Ok(VerifyReport {
431        mode: if quick { "quick" } else { "deep" }.to_string(),
432        checked,
433        ok,
434        missing,
435        corrupt,
436        complete,
437    })
438}
439
440// ─────────────────────────────────────────────────────────────────────────────
441// status (read) — non-failing presence report
442// ─────────────────────────────────────────────────────────────────────────────
443
444/// Report which cataloged assets are present locally and how many bytes remain
445/// to restore. Never fails on a missing asset (that is `verify`'s job); it does
446/// fail on a malformed manifest.
447pub fn status(store: &Store) -> crate::Result<StatusReport> {
448    let records = read_manifest(store)?;
449    let mut present = 0usize;
450    let mut missing = 0usize;
451    let mut required_missing = 0usize;
452    let mut optional_missing = 0usize;
453    let mut bytes_total = 0u64;
454    let mut bytes_missing = 0u64;
455    let mut assets = Vec::with_capacity(records.len());
456
457    for rec in &records {
458        // Saturating: `rec.bytes` is deserialized verbatim from a hand-editable /
459        // poisoned `assets.jsonl` with no clamp. An absurd value (~u64::MAX)
460        // summed with unchecked `+=` ABORTS in debug (overflow-checks) and
461        // silently WRAPS in release — and `status` is contractually non-failing.
462        bytes_total = bytes_total.saturating_add(rec.bytes);
463        // Resolve through the same containment guard `scan` and `verify` use:
464        // the module contract is that the guard applies "wherever a path is read
465        // or resolved", and an unguarded `is_file()` here let a poisoned/hand-
466        // edited manifest path (`../outside.txt`) report `present` (and count its
467        // bytes) while `verify` reported it `corrupt` — two read commands on the
468        // same store disagreeing, plus a path-existence oracle outside the store.
469        // An escaping record is treated as not-present (missing), matching verify.
470        let is_present = store.open_regular(Path::new(&rec.path)).is_ok();
471        let state = if is_present {
472            present += 1;
473            "present"
474        } else {
475            missing += 1;
476            bytes_missing = bytes_missing.saturating_add(rec.bytes);
477            if rec.required {
478                required_missing += 1;
479            } else {
480                optional_missing += 1;
481            }
482            "missing"
483        };
484        assets.push(AssetState {
485            path: rec.path.clone(),
486            sha256: rec.sha256.clone(),
487            bytes: rec.bytes,
488            required: rec.required,
489            state: state.to_string(),
490        });
491    }
492
493    Ok(StatusReport {
494        total: records.len(),
495        present,
496        missing,
497        required_missing,
498        optional_missing,
499        bytes_total,
500        bytes_missing,
501        assets,
502    })
503}
504
505// ─────────────────────────────────────────────────────────────────────────────
506// paths (read) — the VCS-neutral path list
507// ─────────────────────────────────────────────────────────────────────────────
508
509/// The cataloged asset paths, sorted ascending. The VCS-neutral list a harness
510/// feeds into a `.gitignore` managed block or a sync-service exclude. db.md
511/// itself never writes any ignore file.
512///
513/// Every emitted path is routed through the same containment guard `scan`,
514/// `verify`, and `status` use — the module contract is that the guard applies
515/// "wherever a path is read or resolved" (SPEC § Assets > Path safety). A
516/// poisoned / hand-edited manifest path that escapes the store (absolute, or a
517/// `..` traversal — the `merge=union`-corruption state SPEC anticipates) is
518/// OMITTED, so this list — which a harness pipes straight into a `.gitignore`
519/// managed block or a sync-exclude — can never carry an out-of-store path. The
520/// list analog of how `verify` counts an escaping record corrupt and `status`
521/// counts it missing: a path that can't be a real store member is left out.
522pub fn paths(store: &Store) -> crate::Result<Vec<String>> {
523    Ok(read_manifest(store)?
524        .into_iter()
525        .filter(|r| store.capability_relative(Path::new(&r.path)).is_ok())
526        .map(|r| r.path)
527        .collect())
528}
529
530// ─────────────────────────────────────────────────────────────────────────────
531// Declaration parsing (shared with `validate`)
532// ─────────────────────────────────────────────────────────────────────────────
533
534/// Read all `asset:` / `assets:` declarations from a parsed frontmatter.
535///
536/// `asset: <path>` is a single required declaration. `assets:` is a list whose
537/// items are either a bare path string (required) or a `{ path, required }`
538/// mapping. Both keys may be present.
539pub fn declared_assets(fm: &parser::Frontmatter) -> Vec<Declaration> {
540    let mut out = Vec::new();
541    if let Some(v) = fm.get("asset") {
542        collect_declarations(&v, &mut out);
543    }
544    if let Some(v) = fm.get("assets") {
545        collect_declarations(&v, &mut out);
546    }
547    out
548}
549
550/// Read declarations from an already-parsed YAML mapping. Used by
551/// [`crate::validate`], which holds the parsed mapping and need not re-read the
552/// file. Equivalent to [`declared_assets`] but keyed off a raw map.
553pub fn declarations_from_yaml_map(map: &BTreeMap<String, Value>) -> Vec<Declaration> {
554    let mut out = Vec::new();
555    if let Some(v) = map.get("asset") {
556        collect_declarations(v, &mut out);
557    }
558    if let Some(v) = map.get("assets") {
559        collect_declarations(v, &mut out);
560    }
561    out
562}
563
564fn collect_declarations(v: &Value, out: &mut Vec<Declaration>) {
565    match v {
566        Value::String(s) => out.push(Declaration {
567            path: s.clone(),
568            required: true,
569        }),
570        Value::Sequence(items) => {
571            for item in items {
572                match item {
573                    Value::String(s) => out.push(Declaration {
574                        path: s.clone(),
575                        required: true,
576                    }),
577                    Value::Mapping(m) => {
578                        let path = m
579                            .get(Value::String("path".to_string()))
580                            .and_then(|x| x.as_str())
581                            .map(|s| s.to_string());
582                        if let Some(path) = path {
583                            let required = m
584                                .get(Value::String("required".to_string()))
585                                .and_then(|x| x.as_bool())
586                                .unwrap_or(true);
587                            out.push(Declaration { path, required });
588                        }
589                    }
590                    _ => {}
591                }
592            }
593        }
594        _ => {}
595    }
596}
597
598// ─────────────────────────────────────────────────────────────────────────────
599// Helpers
600// ─────────────────────────────────────────────────────────────────────────────
601
602/// Normalize a declared asset path to a CANONICAL store-relative forward-slash
603/// string, rejecting absolute paths and any `..` / root component. This is the
604/// lexical guard; [`crate::store::ensure_path_within_store`] is the resolved-path
605/// guard applied before any disk read.
606///
607/// The result is the record key, so it MUST be canonical: `./sources/x.pdf`,
608/// `sources/x.pdf`, and `sources/./x.pdf` all denote the same file and must fold
609/// to the same key `sources/x.pdf`. The path is rebuilt from `Normal` components
610/// only (dropping `CurDir`); hostile `..`/root/prefix components are still hard
611/// errors (never silently sanitized), so a leading `./` is normalized away while
612/// a traversal attempt is rejected.
613pub fn normalize_asset_path(raw: &str) -> Result<String, String> {
614    let trimmed = raw.trim();
615    if trimmed.is_empty() {
616        return Err("empty asset path".to_string());
617    }
618    let p = Path::new(trimmed);
619    if p.is_absolute() {
620        return Err(format!("absolute asset path not allowed: {raw}"));
621    }
622    let mut normal: Vec<&std::ffi::OsStr> = Vec::new();
623    for c in p.components() {
624        match c {
625            Component::ParentDir => return Err(format!("`..` not allowed in asset path: {raw}")),
626            Component::Prefix(_) | Component::RootDir => {
627                return Err(format!("asset path escapes the store: {raw}"))
628            }
629            // A `.` (CurDir) carries no path information — drop it so the key is
630            // canonical and `./x` does not split into a second record from `x`.
631            Component::CurDir => {}
632            Component::Normal(seg) => normal.push(seg),
633        }
634    }
635    if normal.is_empty() {
636        // The path was only `.`/`./` — no actual target.
637        return Err(format!("asset path names no file: {raw}"));
638    }
639    let joined: PathBuf = normal.into_iter().collect();
640    Ok(joined.to_string_lossy().replace('\\', "/"))
641}
642
643fn is_markdown(path: &str) -> bool {
644    Path::new(path)
645        .extension()
646        .and_then(|e| e.to_str())
647        .map(|e| e.eq_ignore_ascii_case("md"))
648        .unwrap_or(false)
649}
650
651fn rel_to_string(p: &Path) -> String {
652    p.to_string_lossy().replace('\\', "/")
653}
654
655/// Stream the file through SHA-256 (constant memory) and return
656/// `(lowercase-hex digest, byte length)`.
657fn sha256_file(mut f: std::fs::File) -> std::io::Result<(String, u64)> {
658    let mut hasher = Sha256::new();
659    let mut buf = [0u8; 65536];
660    let mut total: u64 = 0;
661    loop {
662        let n = f.read(&mut buf)?;
663        if n == 0 {
664            break;
665        }
666        hasher.update(&buf[..n]);
667        total += n as u64;
668    }
669    let digest = hasher.finalize();
670    let mut hex = String::with_capacity(64);
671    for b in digest.iter() {
672        let _ = write!(hex, "{b:02x}");
673    }
674    Ok((hex, total))
675}
676
677/// Best-effort MIME type from the path extension. Defaults to
678/// `application/octet-stream`. This is deterministic (extension-driven), so it
679/// does not break the manifest's rebuild equivalence.
680fn media_type_for(path: &str) -> String {
681    let ext = Path::new(path)
682        .extension()
683        .and_then(|e| e.to_str())
684        .unwrap_or("")
685        .to_ascii_lowercase();
686    let mt = match ext.as_str() {
687        "pdf" => "application/pdf",
688        "png" => "image/png",
689        "jpg" | "jpeg" => "image/jpeg",
690        "gif" => "image/gif",
691        "webp" => "image/webp",
692        "svg" => "image/svg+xml",
693        "tiff" | "tif" => "image/tiff",
694        "mp4" => "video/mp4",
695        "mov" => "video/quicktime",
696        "webm" => "video/webm",
697        "mkv" => "video/x-matroska",
698        "mp3" => "audio/mpeg",
699        "wav" => "audio/wav",
700        "m4a" => "audio/mp4",
701        "flac" => "audio/flac",
702        "zip" => "application/zip",
703        "gz" | "tgz" => "application/gzip",
704        "tar" => "application/x-tar",
705        "csv" => "text/csv",
706        "tsv" => "text/tab-separated-values",
707        "json" => "application/json",
708        "xml" => "application/xml",
709        "txt" => "text/plain",
710        "vtt" => "text/vtt",
711        "srt" => "application/x-subrip",
712        "html" | "htm" => "text/html",
713        "epub" => "application/epub+zip",
714        "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
715        "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
716        "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
717        "doc" => "application/msword",
718        "xls" => "application/vnd.ms-excel",
719        "ppt" => "application/vnd.ms-powerpoint",
720        _ => "application/octet-stream",
721    };
722    mt.to_string()
723}
724
725/// Non-markdown files under `sources/` that no wrapper declares (the
726/// un-wrappered-drop worklist). Walks the raw filesystem (so it sees files an
727/// ignore mechanism would hide), skips `index.*` sidecars and hidden entries.
728fn find_untracked(store: &Store, declared: &BTreeSet<String>) -> crate::Result<Vec<String>> {
729    let mut out = Vec::new();
730    let paths = match store.walk_regular_files(Path::new("sources")) {
731        Ok(paths) => paths,
732        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(out),
733        Err(error) => return Err(error.into()),
734    };
735    for path in paths {
736        let name = match path.file_name().and_then(|name| name.to_str()) {
737            Some(name) => name,
738            None => continue,
739        };
740        if is_markdown(name) || name == "index.jsonl" {
741            continue;
742        }
743        let rel = rel_to_string(&path);
744        if !declared.contains(&rel) {
745            out.push(rel);
746        }
747    }
748    out.sort();
749    Ok(out)
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755
756    /// Regression (adversarial review): `normalize_asset_path` must fold a
757    /// leading/interior `.` (CurDir) into the canonical key, so `./sources/x.pdf`
758    /// and `sources/x.pdf` are ONE record (not duplicated, byte-double-counted,
759    /// and falsely reported untracked). Traversal / absolute / root stay hard
760    /// errors — folding must never silently sanitize a hostile path.
761    #[test]
762    fn normalize_asset_path_folds_curdir_and_rejects_traversal() {
763        assert_eq!(
764            normalize_asset_path("./sources/x.pdf").unwrap(),
765            "sources/x.pdf"
766        );
767        assert_eq!(
768            normalize_asset_path("sources/x.pdf").unwrap(),
769            "sources/x.pdf"
770        );
771        assert_eq!(
772            normalize_asset_path("sources/./x.pdf").unwrap(),
773            "sources/x.pdf"
774        );
775        assert_eq!(
776            normalize_asset_path("sources/x.pdf/").unwrap(),
777            "sources/x.pdf"
778        );
779
780        // Hostile / structural inputs are still rejected, not sanitized.
781        assert!(normalize_asset_path("../outside.txt").is_err());
782        assert!(normalize_asset_path("sources/../../etc/passwd").is_err());
783        assert!(normalize_asset_path("/abs/x.pdf").is_err());
784        // A `.`-only path (or empty) names no file.
785        assert!(normalize_asset_path(".").is_err());
786        assert!(normalize_asset_path("./").is_err());
787        assert!(normalize_asset_path("").is_err());
788    }
789
790    /// Regression (adversarial review #16): a poisoned / hand-edited
791    /// `assets.jsonl` whose `bytes` sum past u64::MAX must NOT abort `status`
792    /// (debug overflow-checks) or silently WRAP (release). `status`/`scan` are
793    /// non-failing reports over an editable manifest, so the byte totals SATURATE.
794    #[test]
795    fn status_and_scan_saturate_on_overflowing_manifest_bytes() {
796        let tmp = tempfile::TempDir::new().unwrap();
797        let root = tmp.path();
798        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
799        // Two in-store records whose byte sizes sum past u64::MAX.
800        std::fs::write(
801            root.join("assets.jsonl"),
802            "{\"path\":\"records/a.bin\",\"sha256\":\"x\",\"bytes\":18446744073709551615,\
803\"media_type\":\"application/octet-stream\",\"wrappers\":[\"records/w.md\"],\"required\":true}\n\
804{\"path\":\"records/b.bin\",\"sha256\":\"y\",\"bytes\":1,\
805\"media_type\":\"application/octet-stream\",\"wrappers\":[\"records/w.md\"],\"required\":true}\n",
806        )
807        .unwrap();
808        let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
809
810        // status: must not panic; totals saturate at u64::MAX (both assets are
811        // missing from disk, so bytes_missing accumulates them too).
812        let report = status(&store).expect("status is non-failing on a poisoned manifest");
813        assert_eq!(
814            report.bytes_total,
815            u64::MAX,
816            "byte total must saturate, not wrap"
817        );
818        assert_eq!(
819            report.bytes_missing,
820            u64::MAX,
821            "missing bytes must saturate too"
822        );
823        assert_eq!(report.total, 2);
824
825        // scan's `.sum()` over the same records must likewise not overflow.
826        scan(&store, true, false).expect("scan must not overflow on a poisoned manifest");
827    }
828
829    /// Build a minimal store with one wrapper declaring one present asset, and
830    /// return `(store, canonical_manifest_string)` after an initial scan.
831    fn store_with_one_asset() -> (tempfile::TempDir, Store, String) {
832        let tmp = tempfile::TempDir::new().unwrap();
833        let root = tmp.path();
834        std::fs::create_dir_all(root.join("sources")).unwrap();
835        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
836        std::fs::write(
837            root.join("sources/a.pdf.md"),
838            "---\ntype: pdf-source\nsummary: x\nasset: sources/a.pdf\n---\nbody\n",
839        )
840        .unwrap();
841        std::fs::write(root.join("sources/a.pdf"), b"PDFBYTES").unwrap();
842        let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
843        let report = scan(&store, false, false).unwrap();
844        assert!(
845            report.wrote,
846            "first scan writes the manifest; report: {report:?}"
847        );
848        let canonical = std::fs::read_to_string(root.join(MANIFEST_FILE)).unwrap();
849        (tmp, store, canonical)
850    }
851
852    /// Regression (adversarial review): `assets scan`'s no-change gate must
853    /// compare the canonical serialization against the on-disk BYTES, not parsed
854    /// records. A duplicate-line manifest (the git `merge=union` recovery case,
855    /// SPEC § Assets) parses — via `read_manifest`'s dedupe-by-path — back to the
856    /// same records, so a records-vs-records gate would call it "no change" and
857    /// leave the non-canonical bytes forever. `scan` must recompact it to the one
858    /// canonical line and report `wrote: true` (mirroring `index::rebuild_all`,
859    /// which always normalizes non-canonical artifacts).
860    #[test]
861    fn scan_recompacts_duplicate_line_manifest() {
862        let (_tmp, store, canonical) = store_with_one_asset();
863        let abs = store.root.join(MANIFEST_FILE);
864
865        // Simulate a git `merge=union`: the same canonical line, twice.
866        std::fs::write(&abs, format!("{canonical}{canonical}")).unwrap();
867        assert_eq!(std::fs::read_to_string(&abs).unwrap().lines().count(), 2);
868
869        let report = scan(&store, false, false).unwrap();
870        assert!(
871            report.wrote,
872            "a non-canonical (duplicate-line) manifest must be recompacted and reported as updated"
873        );
874        let after = std::fs::read_to_string(&abs).unwrap();
875        assert_eq!(
876            after.lines().count(),
877            1,
878            "duplicate lines must collapse to the single canonical line"
879        );
880        assert_eq!(
881            after, canonical,
882            "scan must restore the exact canonical bytes"
883        );
884    }
885
886    /// Regression (adversarial review): a wrongly-sorted / no-trailing-newline
887    /// manifest is also non-canonical on-disk and must be repaired by `scan`,
888    /// even though it parses (after the read-side sort) to the same records.
889    #[test]
890    fn scan_recompacts_noncanonical_byte_layout() {
891        let (_tmp, store, canonical) = store_with_one_asset();
892        let abs = store.root.join(MANIFEST_FILE);
893
894        // Strip the trailing newline: same record, non-canonical bytes.
895        std::fs::write(&abs, canonical.trim_end_matches('\n')).unwrap();
896        let report = scan(&store, false, false).unwrap();
897        assert!(
898            report.wrote,
899            "a manifest missing its trailing newline must be recompacted"
900        );
901        assert_eq!(
902            std::fs::read_to_string(&abs).unwrap(),
903            canonical,
904            "scan must restore the canonical trailing newline"
905        );
906    }
907
908    /// Regression (adversarial review): `paths` must enforce the containment
909    /// guard "wherever it reads the manifest" (SPEC § Assets > Path safety),
910    /// matching its sibling reads `verify`/`status`. A poisoned / hand-edited
911    /// `assets.jsonl` (the `merge=union`-corruption state the SPEC anticipates)
912    /// with an absolute (`/etc/hosts`) and a `..`-traversal recorded path must
913    /// NOT leak those verbatim — they would flow straight into a harness's
914    /// `.gitignore` managed block or sync-exclude. `paths` is a list, so the
915    /// analog of verify-counts-corrupt / status-counts-missing is to OMIT them;
916    /// the legitimate in-store path is still emitted unchanged.
917    #[test]
918    fn paths_omits_store_escaping_records() {
919        let tmp = tempfile::TempDir::new().unwrap();
920        let root = tmp.path();
921        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
922        // One legitimate in-store record plus two store-escaping ones.
923        std::fs::write(
924            root.join("assets.jsonl"),
925            "{\"path\":\"sources/legit.pdf\",\"sha256\":\"a\",\"bytes\":9,\
926\"media_type\":\"application/pdf\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":true}\n\
927{\"path\":\"../../../../../../etc/passwd\",\"sha256\":\"b\",\"bytes\":4096,\
928\"media_type\":\"text/plain\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":false}\n\
929{\"path\":\"/etc/hosts\",\"sha256\":\"c\",\"bytes\":4096,\
930\"media_type\":\"text/plain\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":false}\n",
931        )
932        .unwrap();
933        let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
934
935        let out = paths(&store).expect("paths is non-failing on a poisoned manifest");
936        assert_eq!(
937            out,
938            vec!["sources/legit.pdf".to_string()],
939            "only the safe in-store path is emitted; escaping paths are omitted"
940        );
941        assert!(
942            !out.iter().any(|p| p.starts_with('/') || p.contains("..")),
943            "no absolute or `..` path may ever leak from `paths`: {out:?}"
944        );
945    }
946
947    /// A clean (all-in-store) manifest must be unchanged by the containment
948    /// filter: every legitimate path is emitted, none dropped.
949    #[test]
950    fn paths_passes_a_clean_manifest_through_unchanged() {
951        let (_tmp, store, _canonical) = store_with_one_asset();
952        let out = paths(&store).expect("paths over a clean manifest");
953        assert_eq!(out, vec!["sources/a.pdf".to_string()]);
954    }
955
956    /// Idempotency must survive the fix: a genuinely-canonical manifest is left
957    /// byte-identical and `scan` reports `wrote: false`. (The old gate already
958    /// did this for parsed-equal records; the byte gate must not regress it.)
959    #[test]
960    fn scan_canonical_manifest_is_left_untouched() {
961        let (_tmp, store, canonical) = store_with_one_asset();
962        let abs = store.root.join(MANIFEST_FILE);
963
964        let report = scan(&store, false, false).unwrap();
965        assert!(
966            !report.wrote,
967            "a canonical, unchanged manifest must not be rewritten"
968        );
969        assert_eq!(
970            std::fs::read_to_string(&abs).unwrap(),
971            canonical,
972            "a no-op rescan must leave the manifest byte-identical"
973        );
974    }
975
976    #[cfg(unix)]
977    #[test]
978    fn manifest_membership_reads_opened_root_after_path_replacement() {
979        use std::os::unix::fs::symlink;
980
981        let sandbox = tempfile::tempdir().unwrap();
982        let root = sandbox.path().join("store");
983        std::fs::create_dir_all(&root).unwrap();
984        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
985        std::fs::write(
986            root.join(MANIFEST_FILE),
987            "{\"path\":\"sources/owned.pdf\",\"sha256\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"bytes\":1,\"media_type\":\"application/pdf\",\"wrappers\":[],\"required\":true}\n",
988        )
989        .unwrap();
990        let store = Store::open_strict(&root).unwrap();
991        let detached = sandbox.path().join("detached");
992        std::fs::rename(&root, &detached).unwrap();
993
994        let replacement = sandbox.path().join("replacement");
995        std::fs::create_dir_all(&replacement).unwrap();
996        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
997        std::fs::write(
998            replacement.join(MANIFEST_FILE),
999            "{\"path\":\"sources/replacement-secret.pdf\",\"sha256\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"bytes\":1,\"media_type\":\"application/pdf\",\"wrappers\":[],\"required\":true}\n",
1000        )
1001        .unwrap();
1002        symlink(&replacement, &root).unwrap();
1003
1004        assert_eq!(paths(&store).unwrap(), vec!["sources/owned.pdf"]);
1005    }
1006}