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