Skip to main content

dbmd_core/
assets.rs

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