Skip to main content

dbmd_core/
assets.rs

1//! `assets` — the db.md asset layer.
2//!
3//! Raw assets (PDFs, recordings, large exports — typically binaries too heavy
4//! for Git) belong to a store but live outside the content plane. A content
5//! file (the **wrapper**) declares one via an `asset:` / `assets:` frontmatter
6//! key; this module records each in the root-level `assets.jsonl` manifest:
7//! store-relative path, SHA-256, size, media type, the declaring wrapper(s),
8//! and whether it is required for byte-completeness.
9//!
10//! Any in-store file may be declared — including a markdown content file, whose
11//! bytes are then integrity-tracked here while the content layer keeps parsing,
12//! indexing, and validating it as usual. [`paths`] still omits markdown, so an
13//! ignore mechanism fed from it can never hide a content file from the VCS.
14//!
15//! The manifest is a **pure projection** of (wrappers + asset files on disk):
16//! every field is derivable, so a [`scan`] where the bytes are present
17//! reproduces it byte-for-byte, exactly like `index.jsonl`. db.md never
18//! transports the bytes and never names a storage provider; that is the
19//! hosting/transport layer's job, keyed off the SHA-256. This module never
20//! shells out to git and never touches the network.
21//!
22//! Six operations — three writes, three reads:
23//!   - [`scan`]   (write) discover declared assets, hash present files, rewrite the manifest
24//!   - [`refresh`] (write) re-hash one declared asset and update its manifest row
25//!   - [`refresh_wrapper`] (write) reconcile every declaration in one wrapper
26//!     and update the manifest once
27//!   - [`verify`] (read)  prove the local store is byte-complete for required assets
28//!   - [`status`] (read)  report present / missing without failing
29//!   - [`paths`]  (read)  the store-relative path list (for an ignore mechanism)
30//!
31//! Path safety: every declared path is validated store-relative (no `..`, no
32//! absolute, no escape) via [`crate::store::ensure_path_within_store`] wherever
33//! a path is read or resolved, so a poisoned manifest can never make `scan`
34//! hash, or a restore write, outside the store.
35
36use std::collections::{BTreeMap, BTreeSet};
37use std::fmt::Write as _;
38use std::io::Read as _;
39use std::path::{Component, Path, PathBuf};
40
41use serde::{Deserialize, Serialize};
42use serde_norway::Value;
43use sha2::{Digest, Sha256};
44
45use crate::parser;
46use crate::projection::ProjectionPolicy;
47use crate::store::Store;
48
49/// The manifest file name at the store root.
50pub const MANIFEST_FILE: &str = "assets.jsonl";
51
52/// Frontmatter key used by an append-only wrapper to state that its single
53/// declared asset is the portable replacement for an older asset coordinate.
54pub const SUPERSEDES_ASSET_KEY: &str = "supersedes-asset";
55
56/// One asset record — one line of `assets.jsonl`.
57///
58/// Every field is derivable from the store (wrapper frontmatter + the file on
59/// disk), so the manifest rebuilds byte-for-byte. Field declaration order is
60/// the canonical JSON key order; `wrappers` is always a sorted list (never a
61/// bare string) so serialization is deterministic.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct AssetRecord {
64    /// Store-relative path of the raw bytes, forward-slash, with extension. The
65    /// record key. May differ from `wrappers` (the wrapper is the `.md`).
66    pub path: String,
67    /// Lowercase-hex SHA-256 of the bytes: the integrity check and the provider
68    /// blob key. May repeat across records (identical bytes at two paths).
69    pub sha256: String,
70    /// Size in bytes.
71    pub bytes: u64,
72    /// Best-effort MIME type derived from the path extension.
73    pub media_type: String,
74    /// Store-relative path(s) of the content file(s) that declare this asset,
75    /// sorted ascending. Usually one.
76    pub wrappers: Vec<String>,
77    /// Whether the asset is required for byte-completeness (default `true`;
78    /// `false` only when every declaration marks it optional).
79    pub required: bool,
80}
81
82/// A single `asset:` / `assets:` declaration read from a wrapper's frontmatter.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Declaration {
85    /// The raw store-relative path string as written in frontmatter.
86    pub path: String,
87    /// Whether this declaration marks the asset required (bare string and
88    /// object-without-`required` default to `true`).
89    pub required: bool,
90}
91
92/// A value-free, append-only asset replacement declaration.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct AssetSupersession {
95    /// The older asset coordinate retained as optional evidence.
96    pub original: String,
97    /// The wrapper's single required replacement coordinate.
98    pub replacement: String,
99}
100
101// ─────────────────────────────────────────────────────────────────────────────
102// Reports (serialized directly in `--json`; the CLI renders the text form)
103// ─────────────────────────────────────────────────────────────────────────────
104
105/// Result of [`scan`].
106#[derive(Debug, Serialize)]
107pub struct ScanReport {
108    pub manifest: String,
109    pub cataloged: usize,
110    pub hashed: usize,
111    pub preserved: usize,
112    pub bytes: u64,
113    pub wrote: bool,
114    pub dry_run: bool,
115    pub warnings: Vec<String>,
116    pub untracked: Vec<String>,
117}
118
119/// Result of [`refresh`]. A refresh is the bounded write-through counterpart
120/// to the full-store [`scan`]: it re-hashes one declared asset without touching
121/// unrelated bytes.
122#[derive(Debug, Serialize)]
123pub struct RefreshReport {
124    pub manifest: String,
125    pub path: String,
126    pub sha256: String,
127    pub bytes: u64,
128    pub wrappers: Vec<String>,
129    pub required: bool,
130    /// Older asset coordinates made optional by this wrapper.
131    pub superseded_assets: Vec<String>,
132    pub wrote: bool,
133}
134
135/// Result of [`refresh_wrapper`]. This is the bounded batch counterpart to
136/// [`refresh`]: every asset declared by one wrapper is reconciled, while
137/// unrelated wrappers and bytes are untouched.
138#[derive(Debug, Serialize)]
139pub struct RefreshWrapperReport {
140    pub manifest: String,
141    pub wrapper: String,
142    pub cataloged: usize,
143    pub added: usize,
144    pub removed: usize,
145    pub hashed: usize,
146    pub preserved: usize,
147    pub bytes: u64,
148    pub wrote: bool,
149}
150
151/// One asset's local state, used by [`status`] and [`verify`].
152#[derive(Debug, Serialize)]
153pub struct AssetState {
154    pub path: String,
155    pub sha256: String,
156    pub bytes: u64,
157    pub required: bool,
158    /// `present` / `missing` (status); `ok` / `missing` / `corrupt` (verify).
159    pub state: String,
160}
161
162/// Result of [`status`].
163#[derive(Debug, Serialize)]
164pub struct StatusReport {
165    pub total: usize,
166    pub present: usize,
167    pub missing: usize,
168    pub required_missing: usize,
169    pub optional_missing: usize,
170    pub bytes_total: u64,
171    pub bytes_missing: u64,
172    pub assets: Vec<AssetState>,
173}
174
175/// Result of [`verify`].
176#[derive(Debug, Serialize)]
177pub struct VerifyReport {
178    pub mode: String,
179    pub checked: usize,
180    pub ok: usize,
181    pub missing: Vec<String>,
182    pub corrupt: Vec<String>,
183    /// Exact absent paths declared outside this materialized projection.
184    #[serde(skip_serializing_if = "Vec::is_empty")]
185    pub projected_missing: Vec<String>,
186    /// Whether every byte that belongs to this materialized projection passed.
187    /// Present only for projection-aware verification; unlike `complete`, this
188    /// may be true while explicitly excluded bytes remain absent.
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub projection_complete: Option<bool>,
191    /// True only when every considered required byte is present and valid.
192    pub complete: bool,
193}
194
195// ─────────────────────────────────────────────────────────────────────────────
196// Manifest read / write
197// ─────────────────────────────────────────────────────────────────────────────
198
199/// Read `assets.jsonl` into records, deduped by path (last line wins) and
200/// sorted by path ascending. A missing manifest is an empty store, not an
201/// error. A malformed line is an `InvalidData` error (the CLI surfaces it;
202/// [`crate::validate`] flags it leniently as `ASSET_MANIFEST_MALFORMED`).
203pub fn read_manifest(store: &Store) -> crate::Result<Vec<AssetRecord>> {
204    let text = match store
205        .read_text_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES)
206    {
207        Ok(text) => text,
208        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
209        Err(error) => return Err(error.into()),
210    };
211    let mut by_path: BTreeMap<String, AssetRecord> = BTreeMap::new();
212    for (i, line) in text.lines().enumerate() {
213        if line.trim().is_empty() {
214            continue;
215        }
216        let rec: AssetRecord = serde_json::from_str(line).map_err(|e| {
217            std::io::Error::new(
218                std::io::ErrorKind::InvalidData,
219                format!("{MANIFEST_FILE} line {}: {e}", i + 1),
220            )
221        })?;
222        by_path.insert(rec.path.clone(), rec);
223    }
224    Ok(by_path.into_values().collect())
225}
226
227/// The canonical serialized form of a record set: one JSON line per record,
228/// records sorted by path ascending, trailing newline. An empty record set is
229/// the empty string (the manifest file is removed, not written empty). This is
230/// the SINGLE source of the manifest's byte layout — both [`write_manifest`] and
231/// the [`scan`] no-change gate go through it, so "what scan would write" and
232/// "what's on disk" are compared as the same bytes.
233fn serialize_manifest(records: &[AssetRecord]) -> String {
234    if records.is_empty() {
235        return String::new();
236    }
237    let mut sorted = records.to_vec();
238    sorted.sort_by(|a, b| a.path.cmp(&b.path));
239    let mut out = String::new();
240    for rec in &sorted {
241        let line = serde_json::to_string(rec).expect("AssetRecord serializes");
242        out.push_str(&line);
243        out.push('\n');
244    }
245    out
246}
247
248/// Write the manifest atomically (temp + fsync + rename through the store's
249/// held root capability), records sorted by path ascending. An empty record set
250/// removes the file.
251pub fn write_manifest(store: &Store, records: &[AssetRecord]) -> crate::Result<()> {
252    let abs = Path::new(MANIFEST_FILE);
253    let out = serialize_manifest(records);
254    if out.is_empty() {
255        match store.remove_file(abs) {
256            Ok(()) => {}
257            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
258            Err(error) => return Err(error.into()),
259        }
260        return Ok(());
261    }
262    store.write_atomic(abs, out.as_bytes())?;
263    Ok(())
264}
265
266// ─────────────────────────────────────────────────────────────────────────────
267// scan (write) — rebuild the manifest from wrapper declarations
268// ─────────────────────────────────────────────────────────────────────────────
269
270/// Walk every content file, read its `asset`/`assets` declarations, hash the
271/// present files, and (re)write the manifest. The manifest is a projection: a
272/// path no longer declared by any wrapper drops out. Bytes absent locally but
273/// previously cataloged are preserved (the eviction / disk-relief case) since
274/// they cannot be re-hashed. `dry_run` computes without writing; `untracked`
275/// additionally reports non-markdown files under `sources/` that no wrapper
276/// declares. Never writes when nothing changed (keeps the Git diff and the
277/// `--dry-run`-then-scan idempotent).
278pub fn scan(store: &Store, dry_run: bool, untracked: bool) -> crate::Result<ScanReport> {
279    // Tolerate a malformed existing manifest here: scan rebuilds from the files,
280    // so a corrupt prior file is simply replaced. We still read it (best effort)
281    // to preserve hashes for evicted (absent-but-cataloged) assets.
282    let existing_by_path: BTreeMap<String, AssetRecord> = read_manifest(store)
283        .unwrap_or_default()
284        .into_iter()
285        .map(|r| (r.path.clone(), r))
286        .collect();
287
288    // Aggregate declarations across all content files: path -> (wrappers, required).
289    let mut wrappers_by_path: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
290    let mut required_by_path: BTreeMap<String, bool> = BTreeMap::new();
291    let mut declared_paths: BTreeSet<String> = BTreeSet::new();
292    let mut supersessions: BTreeMap<String, (String, String)> = BTreeMap::new();
293    let mut ambiguous_supersessions: BTreeSet<String> = BTreeSet::new();
294    let mut warnings: Vec<String> = Vec::new();
295
296    for rel in store.walk()? {
297        let text = match store.read_text_bounded(&rel, parser::MAX_DBMD_FILE_BYTES) {
298            Ok(text) => text,
299            Err(_) => continue,
300        };
301        let parsed = match parser::split_frontmatter(&text, &rel) {
302            Ok(parsed) => parsed,
303            Err(_) => continue,
304        };
305        let fm = match parser::Frontmatter::parse(&parsed.frontmatter_yaml, &rel) {
306            Ok(frontmatter) => frontmatter,
307            Err(_) => continue, // unparseable / not a content file: skip
308        };
309        let wrapper = rel_to_string(&rel);
310        for decl in declared_assets(&fm) {
311            let norm = match normalize_asset_path(&decl.path) {
312                Ok(n) => n,
313                Err(e) => {
314                    warnings.push(format!("{wrapper}: {e}"));
315                    continue;
316                }
317            };
318            wrappers_by_path
319                .entry(norm.clone())
320                .or_default()
321                .insert(wrapper.clone());
322            let req = required_by_path.entry(norm.clone()).or_insert(false);
323            *req = *req || decl.required;
324            declared_paths.insert(norm);
325        }
326        match asset_supersession(&fm) {
327            Ok(Some(supersession)) => {
328                if let Some((prior, prior_wrapper)) = supersessions.get(&supersession.original) {
329                    if prior != &supersession.replacement {
330                        ambiguous_supersessions.insert(supersession.original.clone());
331                        warnings.push(format!(
332                            "{wrapper}: `{SUPERSEDES_ASSET_KEY}` conflicts with {prior_wrapper} for {}",
333                            supersession.original
334                        ));
335                    }
336                } else {
337                    supersessions.insert(
338                        supersession.original,
339                        (supersession.replacement, wrapper.clone()),
340                    );
341                }
342            }
343            Ok(None) => {}
344            Err(error) => warnings.push(format!("{wrapper}: {error}")),
345        }
346    }
347
348    let cyclic_supersessions = supersession_cycle_members(&supersessions);
349    for original in &cyclic_supersessions {
350        if let Some((_, wrapper)) = supersessions.get(original) {
351            warnings.push(format!(
352                "{wrapper}: `{SUPERSEDES_ASSET_KEY}` participates in a replacement cycle at {original}"
353            ));
354        }
355    }
356    for (original, (replacement, wrapper)) in supersessions {
357        if ambiguous_supersessions.contains(&original) {
358            continue;
359        }
360        if cyclic_supersessions.contains(&original) {
361            continue;
362        }
363        if !wrappers_by_path.contains_key(&replacement) {
364            warnings.push(format!(
365                "{wrapper}: replacement asset `{replacement}` is not declared"
366            ));
367            continue;
368        }
369        if !wrappers_by_path.contains_key(&original) && !existing_by_path.contains_key(&original) {
370            warnings.push(format!(
371                "{wrapper}: superseded asset `{original}` is neither declared nor cataloged"
372            ));
373            continue;
374        }
375        wrappers_by_path
376            .entry(original.clone())
377            .or_default()
378            .insert(wrapper);
379        required_by_path.insert(original.clone(), false);
380        declared_paths.insert(original);
381    }
382
383    // Build records.
384    let mut records: Vec<AssetRecord> = Vec::new();
385    let mut hashed = 0usize;
386    let mut preserved = 0usize;
387    for (path, wrappers) in &wrappers_by_path {
388        let required = *required_by_path.get(path).unwrap_or(&true);
389        let wrappers: Vec<String> = wrappers.iter().cloned().collect();
390
391        // Belt-and-suspenders containment check before any disk read.
392        let abs = match store.capability_relative(Path::new(path)) {
393            Ok(p) => p,
394            Err(_) => {
395                warnings.push(format!("{path}: escapes the store root; skipped"));
396                continue;
397            }
398        };
399
400        match store.open_regular(abs) {
401            Ok(file) => {
402                let (sha256, bytes) = sha256_file(file)?;
403                records.push(AssetRecord {
404                    path: path.clone(),
405                    sha256,
406                    bytes,
407                    media_type: media_type_for(path),
408                    wrappers,
409                    required,
410                });
411                hashed += 1;
412            }
413            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
414                if let Some(prev) = existing_by_path.get(path) {
415                    // Evicted: bytes gone locally but previously cataloged. Preserve the
416                    // committed hash/size (we cannot re-hash what is not here).
417                    records.push(AssetRecord {
418                        path: path.clone(),
419                        sha256: prev.sha256.clone(),
420                        bytes: prev.bytes,
421                        media_type: media_type_for(path),
422                        wrappers,
423                        required,
424                    });
425                    preserved += 1;
426                } else {
427                    warnings.push(format!(
428                        "{path}: declared but absent and never cataloged; cannot hash (skipped)"
429                    ));
430                }
431            }
432            Err(error) => {
433                warnings.push(format!(
434                    "{path}: is not a readable regular in-store file: {error}"
435                ));
436            }
437        }
438    }
439    records.sort_by(|a, b| a.path.cmp(&b.path));
440
441    // Saturating: poisoned-manifest `bytes` can overflow a plain `.sum()` (debug
442    // abort / release wrap); see `status`.
443    let bytes: u64 = records.iter().fold(0u64, |a, r| a.saturating_add(r.bytes));
444    let cataloged = records.len();
445
446    let untracked_list = if untracked {
447        find_untracked(store, &declared_paths)?
448    } else {
449        Vec::new()
450    };
451
452    // Only write when the canonical BYTES differ from what's on disk. Comparing
453    // parsed records would miss non-canonical on-disk state — duplicate lines
454    // from a git `merge=union`, a wrong sort, a missing trailing newline — since
455    // `read_manifest` dedupes-by-path and sorts, so a poisoned file parses back
456    // equal to the freshly computed records and the no-op gate never repairs it.
457    // We instead compare the canonical serialization against the raw on-disk
458    // bytes, so `scan` recompacts a non-canonical manifest (mirroring how
459    // `index::rebuild_all` always normalizes its artifacts). This is also the
460    // documented `merge=union` recovery (SPEC § Assets).
461    let mut wrote = false;
462    if !dry_run {
463        let canonical = serialize_manifest(&records);
464        let on_disk = match store
465            .read_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES)
466        {
467            Ok(bytes) => bytes,
468            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
469            Err(error) => return Err(error.into()),
470        };
471        if on_disk != canonical.as_bytes() {
472            write_manifest(store, &records)?;
473            wrote = true;
474        }
475    }
476
477    Ok(ScanReport {
478        manifest: MANIFEST_FILE.to_string(),
479        cataloged,
480        hashed,
481        preserved,
482        bytes,
483        wrote,
484        dry_run,
485        warnings,
486        untracked: untracked_list,
487    })
488}
489
490fn supersession_cycle_members(
491    supersessions: &BTreeMap<String, (String, String)>,
492) -> BTreeSet<String> {
493    let mut cyclic = BTreeSet::new();
494    for origin in supersessions.keys() {
495        let mut order = Vec::new();
496        let mut positions = BTreeMap::new();
497        let mut current = origin.as_str();
498        while let Some((next, _)) = supersessions.get(current) {
499            if let Some(start) = positions.get(current).copied() {
500                cyclic.extend(order[start..].iter().cloned());
501                break;
502            }
503            positions.insert(current.to_string(), order.len());
504            order.push(current.to_string());
505            current = next;
506        }
507    }
508    cyclic
509}
510
511/// Re-hash one asset and write just its canonical manifest record.
512///
513/// `scan` remains the authoritative from-scratch projection. This bounded
514/// operation exists for write-through workflows that just created or changed
515/// one asset. The supplied wrapper must currently declare the exact path.
516/// Existing wrappers recorded for that path are re-read and stale declarations
517/// are dropped; unrelated manifest rows and asset bytes are never walked.
518pub fn refresh(store: &Store, raw_path: &str, raw_wrapper: &str) -> crate::Result<RefreshReport> {
519    let path = normalize_asset_path(raw_path)
520        .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
521
522    let wrapper_path = normalize_asset_path(raw_wrapper)
523        .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
524    if !is_markdown(&wrapper_path)
525        || !(wrapper_path.starts_with("sources/") || wrapper_path.starts_with("records/"))
526    {
527        return Err(std::io::Error::new(
528            std::io::ErrorKind::InvalidInput,
529            "wrapper must be a sources/ or records/ markdown content path",
530        )
531        .into());
532    }
533
534    let declaration = |wrapper: &str| -> crate::Result<Option<bool>> {
535        let text =
536            store.read_text_bounded(Path::new(wrapper), crate::parser::MAX_DBMD_FILE_BYTES)?;
537        let parsed = parser::split_frontmatter(&text, Path::new(wrapper))?;
538        let fm = parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(wrapper))?;
539        let mut found = false;
540        let mut required = false;
541        for declaration in declared_assets(&fm) {
542            let declared = normalize_asset_path(&declaration.path).map_err(|message| {
543                std::io::Error::new(std::io::ErrorKind::InvalidInput, message)
544            })?;
545            if declared == path {
546                found = true;
547                required |= declaration.required;
548            }
549        }
550        Ok(found.then_some(required))
551    };
552
553    let Some(requested_required) = declaration(&wrapper_path)? else {
554        return Err(std::io::Error::new(
555            std::io::ErrorKind::InvalidInput,
556            format!("wrapper `{wrapper_path}` does not declare asset `{path}`"),
557        )
558        .into());
559    };
560    let requested_supersession = {
561        let text = store
562            .read_text_bounded(Path::new(&wrapper_path), crate::parser::MAX_DBMD_FILE_BYTES)?;
563        let parsed = parser::split_frontmatter(&text, Path::new(&wrapper_path))?;
564        let fm = parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&wrapper_path))?;
565        asset_supersession(&fm)
566            .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?
567    };
568    if requested_supersession
569        .as_ref()
570        .is_some_and(|supersession| supersession.replacement != path)
571    {
572        return Err(std::io::Error::new(
573            std::io::ErrorKind::InvalidInput,
574            format!("wrapper `{wrapper_path}` supersedes an asset with a different replacement"),
575        )
576        .into());
577    }
578
579    let existing = read_manifest(store)?;
580    let mut wrappers = BTreeSet::from([wrapper_path.clone()]);
581    if let Some(record) = existing.iter().find(|record| record.path == path) {
582        wrappers.extend(record.wrappers.iter().cloned());
583    }
584    let mut live_wrappers = Vec::new();
585    let mut required = requested_required;
586    for wrapper in wrappers {
587        if wrapper != wrapper_path && !store.regular_file_exists(Path::new(&wrapper))? {
588            // Missing historical wrappers are stale declarations. A wrapper
589            // that still exists but cannot be parsed is not stale: refusing
590            // keeps a targeted refresh from silently hiding store corruption.
591            continue;
592        }
593        match declaration(&wrapper) {
594            Ok(Some(wrapper_required)) => {
595                required |= wrapper_required;
596                live_wrappers.push(wrapper);
597            }
598            Ok(None) => {}
599            Err(error) => return Err(error),
600        }
601    }
602    live_wrappers.sort();
603
604    let asset_path = store.capability_relative(Path::new(&path))?;
605    let file = store.open_regular(asset_path)?;
606    let (sha256, bytes) = sha256_file(file)?;
607    let record = AssetRecord {
608        path: path.clone(),
609        sha256: sha256.clone(),
610        bytes,
611        media_type: media_type_for(&path),
612        wrappers: live_wrappers.clone(),
613        required,
614    };
615    let mut next = existing;
616    next.retain(|candidate| candidate.path != path);
617    next.push(record);
618    let mut superseded_assets = Vec::new();
619    if let Some(supersession) = requested_supersession {
620        let original = next
621            .iter_mut()
622            .find(|candidate| candidate.path == supersession.original)
623            .ok_or_else(|| {
624                std::io::Error::new(
625                    std::io::ErrorKind::InvalidInput,
626                    format!(
627                        "superseded asset `{}` has no existing manifest row; run `dbmd assets scan` first",
628                        supersession.original
629                    ),
630                )
631            })?;
632        original.required = false;
633        if !original.wrappers.contains(&wrapper_path) {
634            original.wrappers.push(wrapper_path.clone());
635            original.wrappers.sort();
636        }
637        superseded_assets.push(supersession.original);
638    }
639    next.sort_by(|left, right| left.path.cmp(&right.path));
640
641    let canonical = serialize_manifest(&next);
642    let on_disk =
643        match store.read_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES) {
644            Ok(bytes) => bytes,
645            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
646            Err(error) => return Err(error.into()),
647        };
648    let wrote = on_disk != canonical.as_bytes();
649    if wrote {
650        write_manifest(store, &next)?;
651    }
652
653    Ok(RefreshReport {
654        manifest: MANIFEST_FILE.to_string(),
655        path,
656        sha256,
657        bytes,
658        wrappers: live_wrappers,
659        required,
660        superseded_assets,
661        wrote,
662    })
663}
664
665/// Reconcile every asset declaration in one wrapper and write the canonical
666/// manifest once. This is for bounded generators that own one wrapper with a
667/// changing set of assets: it avoids a full-store [`scan`] and avoids N
668/// manifest rewrites through repeated [`refresh`] calls.
669///
670/// Existing rows declared by other wrappers are preserved. If the requested
671/// wrapper stops declaring a path, only its association is removed; the row
672/// survives when another live wrapper still declares it. Missing bytes may be
673/// preserved only when the exact path already has a manifest row. Asset
674/// supersession remains a one-coordinate [`refresh`] operation because its
675/// append-only provenance semantics are deliberately not batchable.
676pub fn refresh_wrapper(store: &Store, raw_wrapper: &str) -> crate::Result<RefreshWrapperReport> {
677    let wrapper_path = normalize_asset_path(raw_wrapper)
678        .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
679    if !is_markdown(&wrapper_path)
680        || !(wrapper_path.starts_with("sources/") || wrapper_path.starts_with("records/"))
681    {
682        return Err(std::io::Error::new(
683            std::io::ErrorKind::InvalidInput,
684            "wrapper must be a sources/ or records/ markdown content path",
685        )
686        .into());
687    }
688
689    let wrapper_declarations = |wrapper: &str| -> crate::Result<BTreeMap<String, bool>> {
690        let text =
691            store.read_text_bounded(Path::new(wrapper), crate::parser::MAX_DBMD_FILE_BYTES)?;
692        let parsed = parser::split_frontmatter(&text, Path::new(wrapper))?;
693        let fm = parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(wrapper))?;
694        if asset_supersession(&fm)
695            .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?
696            .is_some()
697        {
698            return Err(std::io::Error::new(
699                std::io::ErrorKind::InvalidInput,
700                "refresh-wrapper does not accept supersedes-asset; use assets refresh for that replacement",
701            )
702            .into());
703        }
704        let mut declarations = BTreeMap::new();
705        for declaration in declared_assets(&fm) {
706            let path = normalize_asset_path(&declaration.path).map_err(|message| {
707                std::io::Error::new(std::io::ErrorKind::InvalidInput, message)
708            })?;
709            let required = declarations.entry(path).or_insert(false);
710            *required |= declaration.required;
711        }
712        Ok(declarations)
713    };
714
715    // An empty declaration set is meaningful: a bounded generator may have
716    // removed its last object. Reconciliation must then remove only this
717    // wrapper's old associations instead of forcing a full-store scan.
718    let requested = wrapper_declarations(&wrapper_path)?;
719
720    let existing = read_manifest(store)?;
721    let existing_by_path: BTreeMap<String, AssetRecord> = existing
722        .iter()
723        .cloned()
724        .map(|record| (record.path.clone(), record))
725        .collect();
726    let old_paths: BTreeSet<String> = existing
727        .iter()
728        .filter(|record| record.wrappers.contains(&wrapper_path))
729        .map(|record| record.path.clone())
730        .collect();
731    let requested_paths: BTreeSet<String> = requested.keys().cloned().collect();
732
733    let mut wrapper_cache: BTreeMap<String, Option<BTreeMap<String, bool>>> = BTreeMap::new();
734    let mut live_other_declaration = |wrapper: &str, path: &str| -> crate::Result<Option<bool>> {
735        if !wrapper_cache.contains_key(wrapper) {
736            let declarations = if store.regular_file_exists(Path::new(wrapper))? {
737                Some(wrapper_declarations(wrapper)?)
738            } else {
739                None
740            };
741            wrapper_cache.insert(wrapper.to_string(), declarations);
742        }
743        Ok(wrapper_cache
744            .get(wrapper)
745            .and_then(Option::as_ref)
746            .and_then(|declarations| declarations.get(path).copied()))
747    };
748
749    let mut next = Vec::new();
750    for mut record in existing.iter().cloned() {
751        if requested.contains_key(&record.path) {
752            continue;
753        }
754        if record.wrappers.contains(&wrapper_path) {
755            let mut live_wrappers = Vec::new();
756            let mut required = false;
757            for wrapper in &record.wrappers {
758                if wrapper == &wrapper_path {
759                    continue;
760                }
761                if let Some(wrapper_required) = live_other_declaration(wrapper, &record.path)? {
762                    live_wrappers.push(wrapper.clone());
763                    required |= wrapper_required;
764                }
765            }
766            if live_wrappers.is_empty() {
767                continue;
768            }
769            live_wrappers.sort();
770            record.wrappers = live_wrappers;
771            record.required = required;
772        }
773        next.push(record);
774    }
775
776    let mut hashed = 0usize;
777    let mut preserved = 0usize;
778    let mut bytes_total = 0u64;
779    for (path, requested_required) in &requested {
780        let mut wrappers = vec![wrapper_path.clone()];
781        let mut required = *requested_required;
782        if let Some(existing_record) = existing_by_path.get(path) {
783            for wrapper in &existing_record.wrappers {
784                if wrapper == &wrapper_path {
785                    continue;
786                }
787                if let Some(wrapper_required) = live_other_declaration(wrapper, path)? {
788                    wrappers.push(wrapper.clone());
789                    required |= wrapper_required;
790                }
791            }
792        }
793        wrappers.sort();
794        wrappers.dedup();
795
796        let (sha256, bytes, media_type) = match store.open_regular(Path::new(path)) {
797            Ok(file) => {
798                let (sha256, bytes) = sha256_file(file)?;
799                hashed += 1;
800                (sha256, bytes, media_type_for(path))
801            }
802            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
803                let existing_record = existing_by_path.get(path).ok_or_else(|| {
804                    std::io::Error::new(
805                        std::io::ErrorKind::NotFound,
806                        format!(
807                            "declared asset `{path}` is absent and has no manifest row to preserve"
808                        ),
809                    )
810                })?;
811                preserved += 1;
812                (
813                    existing_record.sha256.clone(),
814                    existing_record.bytes,
815                    existing_record.media_type.clone(),
816                )
817            }
818            Err(error) => return Err(error.into()),
819        };
820        bytes_total = bytes_total.saturating_add(bytes);
821        next.push(AssetRecord {
822            path: path.clone(),
823            sha256,
824            bytes,
825            media_type,
826            wrappers,
827            required,
828        });
829    }
830
831    next.sort_by(|left, right| left.path.cmp(&right.path));
832    let canonical = serialize_manifest(&next);
833    let on_disk =
834        match store.read_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES) {
835            Ok(bytes) => bytes,
836            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
837            Err(error) => return Err(error.into()),
838        };
839    let wrote = on_disk != canonical.as_bytes();
840    if wrote {
841        write_manifest(store, &next)?;
842    }
843
844    Ok(RefreshWrapperReport {
845        manifest: MANIFEST_FILE.to_string(),
846        wrapper: wrapper_path,
847        cataloged: requested.len(),
848        added: requested_paths.difference(&old_paths).count(),
849        removed: old_paths.difference(&requested_paths).count(),
850        hashed,
851        preserved,
852        bytes: bytes_total,
853        wrote,
854    })
855}
856
857// ─────────────────────────────────────────────────────────────────────────────
858// verify (read) — byte-completeness gate
859// ─────────────────────────────────────────────────────────────────────────────
860
861/// Check that every required asset (plus optional, under `include_optional`) is
862/// present locally and matches the manifest. `quick` = presence + size only
863/// (fast); otherwise a full SHA-256 re-hash. This is a SWEEP (O(asset bytes) in
864/// deep mode), never a loop op. `complete` is true iff nothing is missing or
865/// corrupt in the considered set.
866pub fn verify(store: &Store, include_optional: bool, quick: bool) -> crate::Result<VerifyReport> {
867    verify_inner(store, include_optional, quick, None)
868}
869
870/// Verify the byte completeness of an intentionally partial projection.
871/// Exact listed paths may be absent and are reported as `projected_missing`;
872/// if a listed path is present it is still hashed and any corruption blocks.
873/// Unlisted missing assets and every corrupt asset remain blocking.
874pub fn verify_projection(
875    store: &Store,
876    include_optional: bool,
877    quick: bool,
878    projection: &ProjectionPolicy,
879) -> crate::Result<VerifyReport> {
880    verify_inner(store, include_optional, quick, Some(projection))
881}
882
883fn verify_inner(
884    store: &Store,
885    include_optional: bool,
886    quick: bool,
887    projection: Option<&ProjectionPolicy>,
888) -> crate::Result<VerifyReport> {
889    let records = read_manifest(store)?;
890    let mut missing = Vec::new();
891    let mut corrupt = Vec::new();
892    let mut projected_missing = Vec::new();
893    let mut checked = 0usize;
894
895    for rec in &records {
896        if !rec.required && !include_optional {
897            continue;
898        }
899        let abs = match store.capability_relative(Path::new(&rec.path)) {
900            Ok(p) => p,
901            Err(_) => {
902                // A manifest path that escapes the store is not restorable here.
903                checked += 1;
904                corrupt.push(rec.path.clone());
905                continue;
906            }
907        };
908        let file = match store.open_regular(abs) {
909            Ok(file) => file,
910            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
911                if projection.is_some_and(|policy| policy.excludes_path(&rec.path)) {
912                    projected_missing.push(rec.path.clone());
913                } else {
914                    checked += 1;
915                    missing.push(rec.path.clone());
916                }
917                continue;
918            }
919            Err(_) => {
920                checked += 1;
921                corrupt.push(rec.path.clone());
922                continue;
923            }
924        };
925        checked += 1;
926        if quick {
927            let len = file.metadata()?.len();
928            if len != rec.bytes {
929                corrupt.push(rec.path.clone());
930            }
931        } else {
932            let (sha, bytes) = sha256_file(file)?;
933            if sha != rec.sha256 || bytes != rec.bytes {
934                corrupt.push(rec.path.clone());
935            }
936        }
937    }
938
939    let ok = checked - missing.len() - corrupt.len();
940    let projection_complete = projection.map(|_| missing.is_empty() && corrupt.is_empty());
941    let complete = missing.is_empty() && corrupt.is_empty() && projected_missing.is_empty();
942    Ok(VerifyReport {
943        mode: if quick { "quick" } else { "deep" }.to_string(),
944        checked,
945        ok,
946        missing,
947        corrupt,
948        projected_missing,
949        projection_complete,
950        complete,
951    })
952}
953
954// ─────────────────────────────────────────────────────────────────────────────
955// status (read) — non-failing presence report
956// ─────────────────────────────────────────────────────────────────────────────
957
958/// Report which cataloged assets are present locally and how many bytes remain
959/// to restore. Never fails on a missing asset (that is `verify`'s job); it does
960/// fail on a malformed manifest.
961pub fn status(store: &Store) -> crate::Result<StatusReport> {
962    let records = read_manifest(store)?;
963    let mut present = 0usize;
964    let mut missing = 0usize;
965    let mut required_missing = 0usize;
966    let mut optional_missing = 0usize;
967    let mut bytes_total = 0u64;
968    let mut bytes_missing = 0u64;
969    let mut assets = Vec::with_capacity(records.len());
970
971    for rec in &records {
972        // Saturating: `rec.bytes` is deserialized verbatim from a hand-editable /
973        // poisoned `assets.jsonl` with no clamp. An absurd value (~u64::MAX)
974        // summed with unchecked `+=` ABORTS in debug (overflow-checks) and
975        // silently WRAPS in release — and `status` is contractually non-failing.
976        bytes_total = bytes_total.saturating_add(rec.bytes);
977        // Resolve through the same containment guard `scan` and `verify` use:
978        // the module contract is that the guard applies "wherever a path is read
979        // or resolved", and an unguarded `is_file()` here let a poisoned/hand-
980        // edited manifest path (`../outside.txt`) report `present` (and count its
981        // bytes) while `verify` reported it `corrupt` — two read commands on the
982        // same store disagreeing, plus a path-existence oracle outside the store.
983        // An escaping record is treated as not-present (missing), matching verify.
984        let is_present = store.open_regular(Path::new(&rec.path)).is_ok();
985        let state = if is_present {
986            present += 1;
987            "present"
988        } else {
989            missing += 1;
990            bytes_missing = bytes_missing.saturating_add(rec.bytes);
991            if rec.required {
992                required_missing += 1;
993            } else {
994                optional_missing += 1;
995            }
996            "missing"
997        };
998        assets.push(AssetState {
999            path: rec.path.clone(),
1000            sha256: rec.sha256.clone(),
1001            bytes: rec.bytes,
1002            required: rec.required,
1003            state: state.to_string(),
1004        });
1005    }
1006
1007    Ok(StatusReport {
1008        total: records.len(),
1009        present,
1010        missing,
1011        required_missing,
1012        optional_missing,
1013        bytes_total,
1014        bytes_missing,
1015        assets,
1016    })
1017}
1018
1019// ─────────────────────────────────────────────────────────────────────────────
1020// paths (read) — the VCS-neutral path list
1021// ─────────────────────────────────────────────────────────────────────────────
1022
1023/// The cataloged asset paths, sorted ascending. The VCS-neutral list a harness
1024/// feeds into a `.gitignore` managed block or a sync-service exclude. db.md
1025/// itself never writes any ignore file.
1026///
1027/// Every emitted path is routed through the same containment guard `scan`,
1028/// `verify`, and `status` use — the module contract is that the guard applies
1029/// "wherever a path is read or resolved" (SPEC § Assets > Path safety). A
1030/// poisoned / hand-edited manifest path that escapes the store (absolute, or a
1031/// `..` traversal — the `merge=union`-corruption state SPEC anticipates) is
1032/// OMITTED, so this list — which a harness pipes straight into a `.gitignore`
1033/// managed block or a sync-exclude — can never carry an out-of-store path. The
1034/// list analog of how `verify` counts an escaping record corrupt and `status`
1035/// counts it missing: a path that can't be a real store member is left out.
1036///
1037/// Markdown paths are omitted too: a declared `.md` asset is simultaneously a
1038/// content file, and an ignore feed carrying it could hide store content from
1039/// the VCS. Its bytes stay tracked and verified through the manifest; only this
1040/// ignore-oriented projection excludes it.
1041pub fn paths(store: &Store) -> crate::Result<Vec<String>> {
1042    Ok(read_manifest(store)?
1043        .into_iter()
1044        .filter(|r| store.capability_relative(Path::new(&r.path)).is_ok())
1045        .filter(|r| !is_markdown(&r.path))
1046        .map(|r| r.path)
1047        .collect())
1048}
1049
1050// ─────────────────────────────────────────────────────────────────────────────
1051// Declaration parsing (shared with `validate`)
1052// ─────────────────────────────────────────────────────────────────────────────
1053
1054/// Read all `asset:` / `assets:` declarations from a parsed frontmatter.
1055///
1056/// `asset: <path>` is a single required declaration. `assets:` is a list whose
1057/// items are either a bare path string (required) or a `{ path, required }`
1058/// mapping. Both keys may be present.
1059pub fn declared_assets(fm: &parser::Frontmatter) -> Vec<Declaration> {
1060    let mut out = Vec::new();
1061    if let Some(v) = fm.get("asset") {
1062        collect_declarations(&v, &mut out);
1063    }
1064    if let Some(v) = fm.get("assets") {
1065        collect_declarations(&v, &mut out);
1066    }
1067    out
1068}
1069
1070/// Read declarations from an already-parsed YAML mapping. Used by
1071/// [`crate::validate`], which holds the parsed mapping and need not re-read the
1072/// file. Equivalent to [`declared_assets`] but keyed off a raw map.
1073pub fn declarations_from_yaml_map(map: &BTreeMap<String, Value>) -> Vec<Declaration> {
1074    let mut out = Vec::new();
1075    if let Some(v) = map.get("asset") {
1076        collect_declarations(v, &mut out);
1077    }
1078    if let Some(v) = map.get("assets") {
1079        collect_declarations(v, &mut out);
1080    }
1081    out
1082}
1083
1084/// Parse the optional append-only asset supersession contract from typed
1085/// frontmatter. The wrapper must declare exactly one required replacement
1086/// asset; the older coordinate stays in the manifest as optional evidence.
1087pub fn asset_supersession(fm: &parser::Frontmatter) -> Result<Option<AssetSupersession>, String> {
1088    asset_supersession_from_parts(fm.get(SUPERSEDES_ASSET_KEY).as_ref(), declared_assets(fm))
1089}
1090
1091/// Raw-map equivalent of [`asset_supersession`] for the validation sweep.
1092pub fn asset_supersession_from_yaml_map(
1093    map: &BTreeMap<String, Value>,
1094) -> Result<Option<AssetSupersession>, String> {
1095    asset_supersession_from_parts(
1096        map.get(SUPERSEDES_ASSET_KEY),
1097        declarations_from_yaml_map(map),
1098    )
1099}
1100
1101fn asset_supersession_from_parts(
1102    value: Option<&Value>,
1103    declarations: Vec<Declaration>,
1104) -> Result<Option<AssetSupersession>, String> {
1105    let Some(value) = value else {
1106        return Ok(None);
1107    };
1108    let Value::String(original) = value else {
1109        return Err(format!("`{SUPERSEDES_ASSET_KEY}` must be one asset path"));
1110    };
1111    if declarations.len() != 1 || !declarations[0].required {
1112        return Err(format!(
1113            "a `{SUPERSEDES_ASSET_KEY}` wrapper must declare exactly one required replacement asset"
1114        ));
1115    }
1116    let original = normalize_asset_path(original)?;
1117    let replacement = normalize_asset_path(&declarations[0].path)?;
1118    if original == replacement {
1119        return Err(format!(
1120            "`{SUPERSEDES_ASSET_KEY}` cannot name the wrapper's replacement asset"
1121        ));
1122    }
1123    Ok(Some(AssetSupersession {
1124        original,
1125        replacement,
1126    }))
1127}
1128
1129fn collect_declarations(v: &Value, out: &mut Vec<Declaration>) {
1130    match v {
1131        Value::String(s) => out.push(Declaration {
1132            path: s.clone(),
1133            required: true,
1134        }),
1135        Value::Sequence(items) => {
1136            for item in items {
1137                match item {
1138                    Value::String(s) => out.push(Declaration {
1139                        path: s.clone(),
1140                        required: true,
1141                    }),
1142                    Value::Mapping(m) => {
1143                        let path = m
1144                            .get(Value::String("path".to_string()))
1145                            .and_then(|x| x.as_str())
1146                            .map(|s| s.to_string());
1147                        if let Some(path) = path {
1148                            let required = m
1149                                .get(Value::String("required".to_string()))
1150                                .and_then(|x| x.as_bool())
1151                                .unwrap_or(true);
1152                            out.push(Declaration { path, required });
1153                        }
1154                    }
1155                    _ => {}
1156                }
1157            }
1158        }
1159        _ => {}
1160    }
1161}
1162
1163// ─────────────────────────────────────────────────────────────────────────────
1164// Helpers
1165// ─────────────────────────────────────────────────────────────────────────────
1166
1167/// Normalize a declared asset path to a CANONICAL store-relative forward-slash
1168/// string, rejecting absolute paths and any `..` / root component. This is the
1169/// lexical guard; [`crate::store::ensure_path_within_store`] is the resolved-path
1170/// guard applied before any disk read.
1171///
1172/// The result is the record key, so it MUST be canonical: `./sources/x.pdf`,
1173/// `sources/x.pdf`, and `sources/./x.pdf` all denote the same file and must fold
1174/// to the same key `sources/x.pdf`. The path is rebuilt from `Normal` components
1175/// only (dropping `CurDir`); hostile `..`/root/prefix components are still hard
1176/// errors (never silently sanitized), so a leading `./` is normalized away while
1177/// a traversal attempt is rejected.
1178pub fn normalize_asset_path(raw: &str) -> Result<String, String> {
1179    let trimmed = raw.trim();
1180    if trimmed.is_empty() {
1181        return Err("empty asset path".to_string());
1182    }
1183    let p = Path::new(trimmed);
1184    if p.is_absolute() {
1185        return Err(format!("absolute asset path not allowed: {raw}"));
1186    }
1187    let mut normal: Vec<&std::ffi::OsStr> = Vec::new();
1188    for c in p.components() {
1189        match c {
1190            Component::ParentDir => return Err(format!("`..` not allowed in asset path: {raw}")),
1191            Component::Prefix(_) | Component::RootDir => {
1192                return Err(format!("asset path escapes the store: {raw}"))
1193            }
1194            // A `.` (CurDir) carries no path information — drop it so the key is
1195            // canonical and `./x` does not split into a second record from `x`.
1196            Component::CurDir => {}
1197            Component::Normal(seg) => normal.push(seg),
1198        }
1199    }
1200    if normal.is_empty() {
1201        // The path was only `.`/`./` — no actual target.
1202        return Err(format!("asset path names no file: {raw}"));
1203    }
1204    let joined: PathBuf = normal.into_iter().collect();
1205    Ok(joined.to_string_lossy().replace('\\', "/"))
1206}
1207
1208fn is_markdown(path: &str) -> bool {
1209    Path::new(path)
1210        .extension()
1211        .and_then(|e| e.to_str())
1212        .map(|e| e.eq_ignore_ascii_case("md"))
1213        .unwrap_or(false)
1214}
1215
1216fn rel_to_string(p: &Path) -> String {
1217    p.to_string_lossy().replace('\\', "/")
1218}
1219
1220/// Stream the file through SHA-256 (constant memory) and return
1221/// `(lowercase-hex digest, byte length)`.
1222fn sha256_file(mut f: std::fs::File) -> std::io::Result<(String, u64)> {
1223    let mut hasher = Sha256::new();
1224    let mut buf = [0u8; 65536];
1225    let mut total: u64 = 0;
1226    loop {
1227        let n = f.read(&mut buf)?;
1228        if n == 0 {
1229            break;
1230        }
1231        hasher.update(&buf[..n]);
1232        total += n as u64;
1233    }
1234    let digest = hasher.finalize();
1235    let mut hex = String::with_capacity(64);
1236    for b in digest.iter() {
1237        let _ = write!(hex, "{b:02x}");
1238    }
1239    Ok((hex, total))
1240}
1241
1242/// Best-effort MIME type from the path extension. Defaults to
1243/// `application/octet-stream`. This is deterministic (extension-driven), so it
1244/// does not break the manifest's rebuild equivalence.
1245fn media_type_for(path: &str) -> String {
1246    let ext = Path::new(path)
1247        .extension()
1248        .and_then(|e| e.to_str())
1249        .unwrap_or("")
1250        .to_ascii_lowercase();
1251    let mt = match ext.as_str() {
1252        "pdf" => "application/pdf",
1253        "png" => "image/png",
1254        "jpg" | "jpeg" => "image/jpeg",
1255        "gif" => "image/gif",
1256        "webp" => "image/webp",
1257        "svg" => "image/svg+xml",
1258        "tiff" | "tif" => "image/tiff",
1259        "mp4" => "video/mp4",
1260        "mov" => "video/quicktime",
1261        "webm" => "video/webm",
1262        "mkv" => "video/x-matroska",
1263        "mp3" => "audio/mpeg",
1264        "wav" => "audio/wav",
1265        "m4a" => "audio/mp4",
1266        "flac" => "audio/flac",
1267        "zip" => "application/zip",
1268        "gz" | "tgz" => "application/gzip",
1269        "tar" => "application/x-tar",
1270        "csv" => "text/csv",
1271        "tsv" => "text/tab-separated-values",
1272        "md" | "markdown" => "text/markdown",
1273        "json" => "application/json",
1274        "xml" => "application/xml",
1275        "txt" => "text/plain",
1276        "vtt" => "text/vtt",
1277        "srt" => "application/x-subrip",
1278        "html" | "htm" => "text/html",
1279        "epub" => "application/epub+zip",
1280        "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1281        "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1282        "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
1283        "doc" => "application/msword",
1284        "xls" => "application/vnd.ms-excel",
1285        "ppt" => "application/vnd.ms-powerpoint",
1286        _ => "application/octet-stream",
1287    };
1288    mt.to_string()
1289}
1290
1291/// Non-markdown files under `sources/` that no wrapper declares (the
1292/// un-wrappered-drop worklist). Walks the raw filesystem (so it sees files an
1293/// ignore mechanism would hide), skips `index.*` sidecars and hidden entries.
1294fn find_untracked(store: &Store, declared: &BTreeSet<String>) -> crate::Result<Vec<String>> {
1295    let mut out = Vec::new();
1296    let paths = match store.walk_regular_files(Path::new("sources")) {
1297        Ok(paths) => paths,
1298        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(out),
1299        Err(error) => return Err(error.into()),
1300    };
1301    for path in paths {
1302        let name = match path.file_name().and_then(|name| name.to_str()) {
1303            Some(name) => name,
1304            None => continue,
1305        };
1306        if is_markdown(name) || name == "index.jsonl" {
1307            continue;
1308        }
1309        let rel = rel_to_string(&path);
1310        if !declared.contains(&rel) {
1311            out.push(rel);
1312        }
1313    }
1314    out.sort();
1315    Ok(out)
1316}
1317
1318#[cfg(test)]
1319mod tests {
1320    use super::*;
1321
1322    #[test]
1323    fn supersession_cycles_exclude_only_cycle_members() {
1324        let supersessions = BTreeMap::from([
1325            ("a".to_string(), ("b".to_string(), "a.md".to_string())),
1326            ("b".to_string(), ("a".to_string(), "b.md".to_string())),
1327            (
1328                "before".to_string(),
1329                ("a".to_string(), "before.md".to_string()),
1330            ),
1331            (
1332                "clean".to_string(),
1333                ("next".to_string(), "clean.md".to_string()),
1334            ),
1335        ]);
1336        assert_eq!(
1337            supersession_cycle_members(&supersessions),
1338            BTreeSet::from(["a".to_string(), "b".to_string()])
1339        );
1340    }
1341
1342    /// Regression (adversarial review): `normalize_asset_path` must fold a
1343    /// leading/interior `.` (CurDir) into the canonical key, so `./sources/x.pdf`
1344    /// and `sources/x.pdf` are ONE record (not duplicated, byte-double-counted,
1345    /// and falsely reported untracked). Traversal / absolute / root stay hard
1346    /// errors — folding must never silently sanitize a hostile path.
1347    #[test]
1348    fn normalize_asset_path_folds_curdir_and_rejects_traversal() {
1349        assert_eq!(
1350            normalize_asset_path("./sources/x.pdf").unwrap(),
1351            "sources/x.pdf"
1352        );
1353        assert_eq!(
1354            normalize_asset_path("sources/x.pdf").unwrap(),
1355            "sources/x.pdf"
1356        );
1357        assert_eq!(
1358            normalize_asset_path("sources/./x.pdf").unwrap(),
1359            "sources/x.pdf"
1360        );
1361        assert_eq!(
1362            normalize_asset_path("sources/x.pdf/").unwrap(),
1363            "sources/x.pdf"
1364        );
1365
1366        // Hostile / structural inputs are still rejected, not sanitized.
1367        assert!(normalize_asset_path("../outside.txt").is_err());
1368        assert!(normalize_asset_path("sources/../../etc/passwd").is_err());
1369        assert!(normalize_asset_path("/abs/x.pdf").is_err());
1370        // A `.`-only path (or empty) names no file.
1371        assert!(normalize_asset_path(".").is_err());
1372        assert!(normalize_asset_path("./").is_err());
1373        assert!(normalize_asset_path("").is_err());
1374    }
1375
1376    /// Regression (adversarial review #16): a poisoned / hand-edited
1377    /// `assets.jsonl` whose `bytes` sum past u64::MAX must NOT abort `status`
1378    /// (debug overflow-checks) or silently WRAP (release). `status`/`scan` are
1379    /// non-failing reports over an editable manifest, so the byte totals SATURATE.
1380    #[test]
1381    fn status_and_scan_saturate_on_overflowing_manifest_bytes() {
1382        let tmp = tempfile::TempDir::new().unwrap();
1383        let root = tmp.path();
1384        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
1385        // Two in-store records whose byte sizes sum past u64::MAX.
1386        std::fs::write(
1387            root.join("assets.jsonl"),
1388            "{\"path\":\"records/a.bin\",\"sha256\":\"x\",\"bytes\":18446744073709551615,\
1389\"media_type\":\"application/octet-stream\",\"wrappers\":[\"records/w.md\"],\"required\":true}\n\
1390{\"path\":\"records/b.bin\",\"sha256\":\"y\",\"bytes\":1,\
1391\"media_type\":\"application/octet-stream\",\"wrappers\":[\"records/w.md\"],\"required\":true}\n",
1392        )
1393        .unwrap();
1394        let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
1395
1396        // status: must not panic; totals saturate at u64::MAX (both assets are
1397        // missing from disk, so bytes_missing accumulates them too).
1398        let report = status(&store).expect("status is non-failing on a poisoned manifest");
1399        assert_eq!(
1400            report.bytes_total,
1401            u64::MAX,
1402            "byte total must saturate, not wrap"
1403        );
1404        assert_eq!(
1405            report.bytes_missing,
1406            u64::MAX,
1407            "missing bytes must saturate too"
1408        );
1409        assert_eq!(report.total, 2);
1410
1411        // scan's `.sum()` over the same records must likewise not overflow.
1412        scan(&store, true, false).expect("scan must not overflow on a poisoned manifest");
1413    }
1414
1415    /// Build a minimal store with one wrapper declaring one present asset, and
1416    /// return `(store, canonical_manifest_string)` after an initial scan.
1417    fn store_with_one_asset() -> (tempfile::TempDir, Store, String) {
1418        let tmp = tempfile::TempDir::new().unwrap();
1419        let root = tmp.path();
1420        std::fs::create_dir_all(root.join("sources")).unwrap();
1421        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
1422        std::fs::write(
1423            root.join("sources/a.pdf.md"),
1424            "---\ntype: pdf-source\nsummary: x\nasset: sources/a.pdf\n---\nbody\n",
1425        )
1426        .unwrap();
1427        std::fs::write(root.join("sources/a.pdf"), b"PDFBYTES").unwrap();
1428        let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
1429        let report = scan(&store, false, false).unwrap();
1430        assert!(
1431            report.wrote,
1432            "first scan writes the manifest; report: {report:?}"
1433        );
1434        let canonical = std::fs::read_to_string(root.join(MANIFEST_FILE)).unwrap();
1435        (tmp, store, canonical)
1436    }
1437
1438    /// Regression (adversarial review): `assets scan`'s no-change gate must
1439    /// compare the canonical serialization against the on-disk BYTES, not parsed
1440    /// records. A duplicate-line manifest (the git `merge=union` recovery case,
1441    /// SPEC § Assets) parses — via `read_manifest`'s dedupe-by-path — back to the
1442    /// same records, so a records-vs-records gate would call it "no change" and
1443    /// leave the non-canonical bytes forever. `scan` must recompact it to the one
1444    /// canonical line and report `wrote: true` (mirroring `index::rebuild_all`,
1445    /// which always normalizes non-canonical artifacts).
1446    #[test]
1447    fn scan_recompacts_duplicate_line_manifest() {
1448        let (_tmp, store, canonical) = store_with_one_asset();
1449        let abs = store.root.join(MANIFEST_FILE);
1450
1451        // Simulate a git `merge=union`: the same canonical line, twice.
1452        std::fs::write(&abs, format!("{canonical}{canonical}")).unwrap();
1453        assert_eq!(std::fs::read_to_string(&abs).unwrap().lines().count(), 2);
1454
1455        let report = scan(&store, false, false).unwrap();
1456        assert!(
1457            report.wrote,
1458            "a non-canonical (duplicate-line) manifest must be recompacted and reported as updated"
1459        );
1460        let after = std::fs::read_to_string(&abs).unwrap();
1461        assert_eq!(
1462            after.lines().count(),
1463            1,
1464            "duplicate lines must collapse to the single canonical line"
1465        );
1466        assert_eq!(
1467            after, canonical,
1468            "scan must restore the exact canonical bytes"
1469        );
1470    }
1471
1472    /// Regression (adversarial review): a wrongly-sorted / no-trailing-newline
1473    /// manifest is also non-canonical on-disk and must be repaired by `scan`,
1474    /// even though it parses (after the read-side sort) to the same records.
1475    #[test]
1476    fn scan_recompacts_noncanonical_byte_layout() {
1477        let (_tmp, store, canonical) = store_with_one_asset();
1478        let abs = store.root.join(MANIFEST_FILE);
1479
1480        // Strip the trailing newline: same record, non-canonical bytes.
1481        std::fs::write(&abs, canonical.trim_end_matches('\n')).unwrap();
1482        let report = scan(&store, false, false).unwrap();
1483        assert!(
1484            report.wrote,
1485            "a manifest missing its trailing newline must be recompacted"
1486        );
1487        assert_eq!(
1488            std::fs::read_to_string(&abs).unwrap(),
1489            canonical,
1490            "scan must restore the canonical trailing newline"
1491        );
1492    }
1493
1494    /// Regression (adversarial review): `paths` must enforce the containment
1495    /// guard "wherever it reads the manifest" (SPEC § Assets > Path safety),
1496    /// matching its sibling reads `verify`/`status`. A poisoned / hand-edited
1497    /// `assets.jsonl` (the `merge=union`-corruption state the SPEC anticipates)
1498    /// with an absolute (`/etc/hosts`) and a `..`-traversal recorded path must
1499    /// NOT leak those verbatim — they would flow straight into a harness's
1500    /// `.gitignore` managed block or sync-exclude. `paths` is a list, so the
1501    /// analog of verify-counts-corrupt / status-counts-missing is to OMIT them;
1502    /// the legitimate in-store path is still emitted unchanged.
1503    #[test]
1504    fn paths_omits_store_escaping_records() {
1505        let tmp = tempfile::TempDir::new().unwrap();
1506        let root = tmp.path();
1507        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
1508        // One legitimate in-store record plus two store-escaping ones.
1509        std::fs::write(
1510            root.join("assets.jsonl"),
1511            "{\"path\":\"sources/legit.pdf\",\"sha256\":\"a\",\"bytes\":9,\
1512\"media_type\":\"application/pdf\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":true}\n\
1513{\"path\":\"../../../../../../etc/passwd\",\"sha256\":\"b\",\"bytes\":4096,\
1514\"media_type\":\"text/plain\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":false}\n\
1515{\"path\":\"/etc/hosts\",\"sha256\":\"c\",\"bytes\":4096,\
1516\"media_type\":\"text/plain\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":false}\n",
1517        )
1518        .unwrap();
1519        let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
1520
1521        let out = paths(&store).expect("paths is non-failing on a poisoned manifest");
1522        assert_eq!(
1523            out,
1524            vec!["sources/legit.pdf".to_string()],
1525            "only the safe in-store path is emitted; escaping paths are omitted"
1526        );
1527        assert!(
1528            !out.iter().any(|p| p.starts_with('/') || p.contains("..")),
1529            "no absolute or `..` path may ever leak from `paths`: {out:?}"
1530        );
1531    }
1532
1533    /// A clean (all-in-store) manifest must be unchanged by the containment
1534    /// filter: every legitimate path is emitted, none dropped.
1535    #[test]
1536    fn paths_passes_a_clean_manifest_through_unchanged() {
1537        let (_tmp, store, _canonical) = store_with_one_asset();
1538        let out = paths(&store).expect("paths over a clean manifest");
1539        assert_eq!(out, vec!["sources/a.pdf".to_string()]);
1540    }
1541
1542    /// Assets accept ANY in-store file — a markdown content file included
1543    /// (dual-plane: its bytes are tracked here while the content layer keeps
1544    /// owning its meaning). `scan`, `refresh`, and `refresh-wrapper` must all
1545    /// take the coordinate without a skip or an error, and `paths` must still
1546    /// OMIT the markdown path so an ignore feed can never hide a content file.
1547    #[test]
1548    fn markdown_content_files_are_accepted_as_assets_and_omitted_from_paths() {
1549        let tmp = tempfile::TempDir::new().unwrap();
1550        let root = tmp.path();
1551        std::fs::create_dir_all(root.join("sources")).unwrap();
1552        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
1553        std::fs::write(
1554            root.join("sources/bundle.md"),
1555            "---\ntype: pdf-source\nsummary: bundle wrapper\nassets:\n  - sources/notes.md\n  - sources/a.pdf\n---\nbody\n",
1556        )
1557        .unwrap();
1558        std::fs::write(
1559            root.join("sources/notes.md"),
1560            "---\ntype: pdf-source\nsummary: a content file doubling as an asset\n---\nnotes\n",
1561        )
1562        .unwrap();
1563        std::fs::write(root.join("sources/a.pdf"), b"PDFBYTES").unwrap();
1564        let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
1565
1566        let report = scan(&store, false, false).unwrap();
1567        assert!(
1568            report.warnings.is_empty(),
1569            "a markdown asset must not be skipped or warned about: {:?}",
1570            report.warnings
1571        );
1572        assert_eq!(
1573            report.cataloged, 2,
1574            "both the pdf and the markdown asset are cataloged"
1575        );
1576
1577        let refreshed = refresh(&store, "sources/notes.md", "sources/bundle.md")
1578            .expect("refresh accepts a markdown asset coordinate");
1579        assert_eq!(refreshed.path, "sources/notes.md");
1580        let reconciled = refresh_wrapper(&store, "sources/bundle.md")
1581            .expect("refresh-wrapper accepts a wrapper declaring a markdown asset");
1582        assert_eq!(reconciled.cataloged, 2);
1583
1584        let manifest = read_manifest(&store).unwrap();
1585        let md_row = manifest
1586            .iter()
1587            .find(|record| record.path == "sources/notes.md")
1588            .expect("the markdown asset has a manifest row");
1589        assert_eq!(md_row.media_type, "text/markdown");
1590
1591        let listed = paths(&store).expect("paths over the mixed manifest");
1592        assert_eq!(
1593            listed,
1594            vec!["sources/a.pdf".to_string()],
1595            "markdown assets are omitted from the ignore-feed list"
1596        );
1597    }
1598
1599    /// Idempotency must survive the fix: a genuinely-canonical manifest is left
1600    /// byte-identical and `scan` reports `wrote: false`. (The old gate already
1601    /// did this for parsed-equal records; the byte gate must not regress it.)
1602    #[test]
1603    fn scan_canonical_manifest_is_left_untouched() {
1604        let (_tmp, store, canonical) = store_with_one_asset();
1605        let abs = store.root.join(MANIFEST_FILE);
1606
1607        let report = scan(&store, false, false).unwrap();
1608        assert!(
1609            !report.wrote,
1610            "a canonical, unchanged manifest must not be rewritten"
1611        );
1612        assert_eq!(
1613            std::fs::read_to_string(&abs).unwrap(),
1614            canonical,
1615            "a no-op rescan must leave the manifest byte-identical"
1616        );
1617    }
1618
1619    #[test]
1620    fn refresh_wrapper_reconciles_one_generated_asset_set_in_one_manifest_write() {
1621        let tmp = tempfile::TempDir::new().unwrap();
1622        let root = tmp.path();
1623        std::fs::create_dir_all(root.join("records/package")).unwrap();
1624        std::fs::create_dir_all(root.join("sources/package/objects")).unwrap();
1625        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
1626        let wrapper = "records/package/current.md";
1627        std::fs::write(
1628            root.join(wrapper),
1629            "---\ntype: package\nsummary: current\nassets:\n  - sources/package/objects/a.blob\n  - sources/package/objects/b.blob\n---\n",
1630        )
1631        .unwrap();
1632        std::fs::write(root.join("sources/package/objects/a.blob"), b"a").unwrap();
1633        std::fs::write(root.join("sources/package/objects/b.blob"), b"bb").unwrap();
1634        let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
1635
1636        let first = refresh_wrapper(&store, wrapper).unwrap();
1637        assert_eq!(first.cataloged, 2);
1638        assert_eq!(first.added, 2);
1639        assert_eq!(first.removed, 0);
1640        assert_eq!(first.hashed, 2);
1641        assert_eq!(first.bytes, 3);
1642        assert!(first.wrote);
1643
1644        let no_change = refresh_wrapper(&store, wrapper).unwrap();
1645        assert!(!no_change.wrote);
1646        assert_eq!(no_change.added, 0);
1647        assert_eq!(no_change.removed, 0);
1648
1649        std::fs::write(
1650            root.join(wrapper),
1651            "---\ntype: package\nsummary: current\nassets:\n  - sources/package/objects/b.blob\n  - sources/package/objects/c.blob\n---\n",
1652        )
1653        .unwrap();
1654        std::fs::write(root.join("sources/package/objects/c.blob"), b"ccc").unwrap();
1655        let changed = refresh_wrapper(&store, wrapper).unwrap();
1656        assert_eq!(changed.cataloged, 2);
1657        assert_eq!(changed.added, 1);
1658        assert_eq!(changed.removed, 1);
1659        assert_eq!(changed.bytes, 5);
1660        assert!(changed.wrote);
1661
1662        let records = read_manifest(&store).unwrap();
1663        let paths: Vec<&str> = records.iter().map(|record| record.path.as_str()).collect();
1664        assert_eq!(
1665            paths,
1666            vec![
1667                "sources/package/objects/b.blob",
1668                "sources/package/objects/c.blob"
1669            ]
1670        );
1671
1672        std::fs::write(
1673            root.join(wrapper),
1674            "---\ntype: package\nsummary: current\n---\n",
1675        )
1676        .unwrap();
1677        let cleared = refresh_wrapper(&store, wrapper).unwrap();
1678        assert_eq!(cleared.cataloged, 0);
1679        assert_eq!(cleared.added, 0);
1680        assert_eq!(cleared.removed, 2);
1681        assert!(cleared.wrote);
1682        assert!(read_manifest(&store).unwrap().is_empty());
1683    }
1684
1685    #[cfg(unix)]
1686    #[test]
1687    fn manifest_membership_reads_opened_root_after_path_replacement() {
1688        use std::os::unix::fs::symlink;
1689
1690        let sandbox = tempfile::tempdir().unwrap();
1691        let root = sandbox.path().join("store");
1692        std::fs::create_dir_all(&root).unwrap();
1693        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
1694        std::fs::write(
1695            root.join(MANIFEST_FILE),
1696            "{\"path\":\"sources/owned.pdf\",\"sha256\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"bytes\":1,\"media_type\":\"application/pdf\",\"wrappers\":[],\"required\":true}\n",
1697        )
1698        .unwrap();
1699        let store = Store::open_strict(&root).unwrap();
1700        let detached = sandbox.path().join("detached");
1701        std::fs::rename(&root, &detached).unwrap();
1702
1703        let replacement = sandbox.path().join("replacement");
1704        std::fs::create_dir_all(&replacement).unwrap();
1705        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
1706        std::fs::write(
1707            replacement.join(MANIFEST_FILE),
1708            "{\"path\":\"sources/replacement-secret.pdf\",\"sha256\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"bytes\":1,\"media_type\":\"application/pdf\",\"wrappers\":[],\"required\":true}\n",
1709        )
1710        .unwrap();
1711        symlink(&replacement, &root).unwrap();
1712
1713        assert_eq!(paths(&store).unwrap(), vec!["sources/owned.pdf"]);
1714    }
1715}