Skip to main content

majordomus_cli/pack/
shard.rs

1//! The written form of a pack: the shards, the index and the manifest, and the reader that
2//! verifies a written pack against its manifest and the profile it names.
3//!
4//! A file travels inside a shard as one block:
5//!
6//! ````text
7//! <!-- majordomus:file {"path":"src/a.rs","mode":"100644","bytes":12,"sha256":"…","eol":true} -->
8//! ## src/a.rs
9//!
10//! ```rust
11//! <the exact content>
12//! ```
13//! <!-- majordomus:end -->
14//! ````
15//!
16//! The heading is what a model's search lands on; the marker is what [`verify`] reads. The
17//! fence is longer than any run of backticks in the content, so no line of the content can
18//! close it, and `eol: false` records a file whose last line had no newline, which the
19//! block adds so that the fence stands on a line of its own.
20
21use std::collections::{BTreeMap, BTreeSet};
22use std::path::Path;
23
24use schemars::JsonSchema;
25use serde::{Deserialize, Serialize};
26
27use super::{
28    DropReason, IndexEntry, PackFinding, PackPlan, PackProfile, Planned, Profiles, ShardPlan,
29    INDEX, MANIFEST, MANIFEST_SCHEMA,
30};
31use crate::economics::context::count_tokens;
32use crate::policy::sha256_bytes_hex;
33
34const MARK: &str = "<!-- majordomus:file ";
35const END: &str = "<!-- majordomus:end -->";
36
37/// The fence for a content: three backticks, or one more than its longest run of them, so
38/// that nothing the content holds can close the block that carries it.
39///
40/// ```
41/// use majordomus_cli::pack::fence_for;
42/// assert_eq!(fence_for("plain"), "```");
43/// assert_eq!(fence_for("a `` b"), "```");
44/// assert_eq!(fence_for("````\ninside\n````"), "`````");
45/// ```
46pub fn fence_for(content: &str) -> String {
47    let mut longest = 0;
48    let mut run = 0;
49    for c in content.chars() {
50        if c == '`' {
51            run += 1;
52            longest = longest.max(run);
53        } else {
54            run = 0;
55        }
56    }
57    "`".repeat((longest + 1).max(3))
58}
59
60fn language(path: &str) -> &'static str {
61    let ext = path.rsplit_once('.').map(|(_, e)| e).unwrap_or("");
62    match ext {
63        "rs" => "rust",
64        "sh" | "bash" => "bash",
65        "md" => "markdown",
66        "yaml" | "yml" => "yaml",
67        "json" | "jsonl" => "json",
68        "toml" => "toml",
69        "js" | "mjs" | "cjs" => "javascript",
70        "ts" => "typescript",
71        "css" => "css",
72        "html" | "tera" => "html",
73        "rhai" => "rust",
74        "py" => "python",
75        _ => "",
76    }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80struct Marker {
81    path: String,
82    mode: String,
83    bytes: u64,
84    sha256: String,
85    eol: bool,
86}
87
88fn block(entry: &IndexEntry, text: &str) -> String {
89    let eol = text.is_empty() || text.ends_with('\n');
90    let marker = Marker {
91        path: entry.path.clone(),
92        mode: entry.mode.clone(),
93        bytes: text.len() as u64,
94        sha256: sha256_bytes_hex(text.as_bytes()),
95        eol,
96    };
97    let fence = fence_for(text);
98    let mut out = String::with_capacity(text.len() + 256);
99    out.push_str(MARK);
100    out.push_str(&serde_json::to_string(&marker).unwrap_or_default());
101    out.push_str(" -->\n## ");
102    out.push_str(&entry.path);
103    out.push_str("\n\n");
104    out.push_str(&fence);
105    out.push_str(language(&entry.path));
106    out.push('\n');
107    out.push_str(text);
108    if !eol {
109        out.push('\n');
110    }
111    out.push_str(&fence);
112    out.push('\n');
113    out.push_str(END);
114    out.push_str("\n\n");
115    out
116}
117
118/// The tokens of the block that carries a file.
119pub(super) fn block_tokens(entry: &IndexEntry, text: &str) -> u64 {
120    count_tokens(&block(entry, text))
121}
122
123/// The file name of shard `n`, after the directory of the first path it carries.
124pub(super) fn shard_name(n: usize, first: &str) -> String {
125    let parts: Vec<&str> = first.split('/').collect();
126    let area = if parts.len() > 2 {
127        parts[..2].join("-")
128    } else if parts.len() == 2 {
129        parts[0].to_string()
130    } else {
131        "root".to_string()
132    };
133    let slug: String = area
134        .chars()
135        .map(|c| {
136            if c.is_ascii_alphanumeric() {
137                c.to_ascii_lowercase()
138            } else {
139                '-'
140            }
141        })
142        .collect();
143    let slug = slug.trim_matches('-');
144    format!("{n:02}-{}.md", if slug.is_empty() { "root" } else { slug })
145}
146
147/// One shard as the manifest records it.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
149pub struct ManifestShard {
150    /// The file name inside the pack.
151    pub file: String,
152    /// The SHA-256 of the file as written.
153    pub sha256: String,
154    /// Its tokens, counted on the file as written.
155    pub tokens: u64,
156    /// The paths it carries, in order.
157    pub paths: Vec<String>,
158}
159
160/// A pack's manifest, `pack.json`.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
162pub struct Manifest {
163    /// `majordomus.pack/v1`.
164    pub schema: String,
165    /// The profile packed.
166    pub profile: String,
167    /// The commit `HEAD` named.
168    pub commit: Option<String>,
169    /// Whether the index held exactly what `HEAD` holds.
170    pub index_matches_head: bool,
171    /// The tokenizer the counts are in.
172    pub tokenizer: String,
173    /// The index file and its digest.
174    pub index: ManifestShard,
175    /// Every shard, in order.
176    pub shards: Vec<ManifestShard>,
177    /// How many files were left out, by reason.
178    pub dropped: BTreeMap<String, usize>,
179}
180
181/// The tokens of the index a plan would write, with each shard counted as planned.
182pub(super) fn index_tokens(profile: &PackProfile, plan: &PackPlan) -> u64 {
183    let shards: Vec<ManifestShard> = plan
184        .shards
185        .iter()
186        .map(|s| ManifestShard {
187            file: s.file.clone(),
188            sha256: String::new(),
189            tokens: s.tokens,
190            paths: Vec::new(),
191        })
192        .collect();
193    count_tokens(&index_text(profile, plan, &shards))
194}
195
196fn index_text(profile: &PackProfile, p: &PackPlan, shards: &[ManifestShard]) -> String {
197    let mut s = String::new();
198    s.push_str(&format!("# Source pack: {}\n\n", profile.title));
199    for para in &profile.orientation {
200        s.push_str(para);
201        s.push_str("\n\n");
202    }
203    s.push_str("## This pack\n\n");
204    s.push_str(&format!(
205        "- commit: `{}`{}\n- profile: `{}`\n- files: {} of {} tracked, {} bytes, {} tokens ({})\n- shards: {} beside this index\n\n",
206        p.commit.as_deref().unwrap_or("none"),
207        if p.index_matches_head { "" } else { " (the index held staged changes this commit does not contain)" },
208        p.profile,
209        p.selected,
210        p.tracked,
211        p.bytes,
212        p.tokens,
213        p.tokenizer,
214        shards.len()
215    ));
216    s.push_str("Every shard holds files in path order. Each file starts with a level-two heading that is its path, followed by its exact content in a fenced block; search for a path to find a file. `pack.json` is the manifest `majordomus pack verify` reads, and uploading it is optional.\n\n");
217    s.push_str("## Shards\n\n| shard | from | to | files | tokens |\n|---|---|---|---|---|\n");
218    for (m, sh) in shards.iter().zip(&p.shards) {
219        s.push_str(&format!(
220            "| `{}` | `{}` | `{}` | {} | {} |\n",
221            m.file, sh.first, sh.last, sh.files, m.tokens
222        ));
223    }
224    s.push_str("\n## What is not here, and why\n\n");
225    s.push_str("The file set is the git index and the content is the index's blobs, so nothing untracked was ever a candidate: no build output, no dependency directory, no cache, no worktree and no local AI-layer state. Of what the index tracks, the profile left out:\n\n");
226    for reason in DropReason::ALL {
227        let Some(n) = p.dropped.get(reason.as_str()) else {
228            continue;
229        };
230        let (word, why) = (reason.as_str(), reason.meaning());
231        s.push_str(&format!("- {word}: {n} — {why}\n"));
232    }
233    if !p.dropped_files.is_empty() {
234        s.push_str("\nEvery file left out for a reason other than `derived`:\n\n");
235        for d in &p.dropped_files {
236            s.push_str(&format!("- `{}` ({})\n", d.path, d.reason.as_str()));
237        }
238    }
239    s
240}
241
242fn header(
243    file: &str,
244    n: usize,
245    of: usize,
246    commit: Option<&str>,
247    profile: &str,
248    sh: &ShardPlan,
249) -> String {
250    format!(
251        "# {file} — shard {n} of {of}\n\nCommit `{}`, profile `{profile}`: {} file(s) from `{}` to `{}`. Each file follows under a heading that is its path.\n\n",
252        commit.unwrap_or("none"),
253        sh.files,
254        sh.first,
255        sh.last
256    )
257}
258
259/// The tokens a shard's header takes when it opens with `first`, with room for the last
260/// path and the counts that are not known yet.
261pub(super) fn header_tokens(first: &str, commit: Option<&str>, profile: &str) -> u64 {
262    let sh = ShardPlan {
263        file: shard_name(99, first),
264        files: 99_999,
265        first: first.into(),
266        last: first.into(),
267        bytes: 0,
268        tokens: 0,
269    };
270    // the last path is not known when the shard opens; the first one's length again is
271    // the estimate, and the slack covers a longer one
272    count_tokens(&header(&sh.file, 99, 99, commit, profile, &sh)) + count_tokens(first) + 8
273}
274
275/// Write the shards, the index and the manifest of a passing plan into `out`.
276pub(super) fn write(planned: &Planned, out: &Path) -> Result<(), String> {
277    let mut texts: Vec<String> = vec![String::new(); planned.plan.shards.len()];
278    let mut paths: Vec<Vec<String>> = vec![Vec::new(); planned.plan.shards.len()];
279    for (i, sh) in planned.plan.shards.iter().enumerate() {
280        texts[i].push_str(&header(
281            &sh.file,
282            i + 1,
283            planned.plan.shards.len(),
284            planned.plan.commit.as_deref(),
285            &planned.plan.profile,
286            sh,
287        ));
288    }
289    for (i, s) in &planned.files {
290        texts[*i].push_str(&block(&s.entry, &s.text));
291        paths[*i].push(s.entry.path.clone());
292    }
293    let mut shards = Vec::new();
294    for ((sh, text), paths) in planned.plan.shards.iter().zip(texts).zip(paths) {
295        put(out, &sh.file, &text)?;
296        shards.push(ManifestShard {
297            file: sh.file.clone(),
298            sha256: sha256_bytes_hex(text.as_bytes()),
299            tokens: count_tokens(&text),
300            paths,
301        });
302    }
303    let index = index_text(&planned.profile, &planned.plan, &shards);
304    put(out, INDEX, &index)?;
305    let manifest = Manifest {
306        schema: MANIFEST_SCHEMA.into(),
307        profile: planned.plan.profile.clone(),
308        commit: planned.plan.commit.clone(),
309        index_matches_head: planned.plan.index_matches_head,
310        tokenizer: planned.plan.tokenizer.clone(),
311        index: ManifestShard {
312            file: INDEX.into(),
313            sha256: sha256_bytes_hex(index.as_bytes()),
314            tokens: count_tokens(&index),
315            paths: Vec::new(),
316        },
317        shards,
318        dropped: planned.plan.dropped.clone(),
319    };
320    // a manifest is plain data and always serialises; were it ever not to, it would be the
321    // manifest that could not be written
322    let json = serde_json::to_string_pretty(&manifest).unwrap_or_default();
323    put(out, MANIFEST, &(json + "\n"))
324}
325
326/// Write one file of a pack, or name the one that could not be written.
327fn put(out: &Path, name: &str, text: &str) -> Result<(), String> {
328    std::fs::write(out.join(name), text).map_err(|e| format!("cannot write {name}: {e}"))
329}
330
331/// The verdict on a written pack: whether it could be read, what its manifest names, how
332/// many files and sources it holds, and every finding against it.
333///
334/// The examples below build a pack of a repository holding `main.rs`.
335///
336/// ```
337/// use majordomus_cli::pack::{build, plan, verify, PackVerdict, Profiles};
338/// # use std::process::Command;
339/// # let dir = tempfile::tempdir().unwrap();
340/// # let (root, share) = (dir.path().join("repo"), dir.path().join("share"));
341/// # std::fs::create_dir_all(&root).unwrap();
342/// # std::fs::create_dir_all(&share).unwrap();
343/// # std::fs::write(share.join("archive.yaml"), "default: chat\nprofiles:\n  - id: chat\n    binary: drop\n    artifacts: drop\n    shards:\n      max_count: 5\n      max_tokens: 4000\n").unwrap();
344/// # let git = |a: &[&str]| assert!(Command::new("git").arg("-C").arg(&root)
345/// #     .args(["-c", "user.email=t@example.com", "-c", "user.name=t"]).args(a)
346/// #     .status().unwrap().success());
347/// # git(&["init", "-q"]);
348/// # std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
349/// # git(&["add", "-A"]);
350/// # git(&["commit", "-q", "-m", "one"]);
351/// let profiles = Profiles::load(&share, &root).unwrap();
352/// let out = dir.path().join("pack");
353/// let v: PackVerdict = build(&plan(&root, &share, None), &root, &out).unwrap();
354/// assert_eq!((v.files, v.sources), (2, 1), "the index and one shard, carrying one file");
355/// assert_eq!(v.profile.as_deref(), Some("chat"));
356/// // a directory with no manifest is not measured, which is not a pass
357/// let v: PackVerdict = verify(dir.path(), &profiles, &root);
358/// assert!(!v.measured && !v.passes);
359/// ```
360#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
361pub struct PackVerdict {
362    /// Whether the pack could be read at all. `false` is not a pass.
363    pub measured: bool,
364    /// Why it could not, when it could not.
365    pub reason: Option<String>,
366    /// The directory read.
367    pub dir: String,
368    /// The profile its manifest names.
369    pub profile: Option<String>,
370    /// The commit its manifest names.
371    pub commit: Option<String>,
372    /// How many files it holds, the index included.
373    pub files: usize,
374    /// How many source files its shards carry.
375    pub sources: usize,
376    /// The tokens of its largest file.
377    pub largest_tokens: u64,
378    /// Every finding. Empty is the passing answer.
379    pub findings: Vec<PackFinding>,
380    /// `true` when the pack was read and nothing was found.
381    pub passes: bool,
382}
383
384fn finding(code: &str, path: Option<&str>, message: String, remedy: &str) -> PackFinding {
385    PackFinding::new(code, path, message, remedy)
386}
387
388/// Verify a written pack: every file the manifest names is there with its digest, nothing
389/// else is, every block's content has the digest its marker records, no carried path is a
390/// binary, an artifact, a link or a worktree under the profile it names, no file of the
391/// pack is over the token budget or holds a NUL byte or a leak, and the pack has no more
392/// files than the profile allows. `root` is the checkout whose path, with the account's
393/// home directory, no file may name. Rebuilding is never the remedy for a finding here: a
394/// pack that fails is not sent.
395///
396/// ```
397/// use majordomus_cli::pack::{build, plan, verify, Profiles};
398/// # use std::process::Command;
399/// # let dir = tempfile::tempdir().unwrap();
400/// # let (root, share) = (dir.path().join("repo"), dir.path().join("share"));
401/// # std::fs::create_dir_all(&root).unwrap();
402/// # std::fs::create_dir_all(&share).unwrap();
403/// # std::fs::write(share.join("archive.yaml"), "default: chat\nprofiles:\n  - id: chat\n    binary: drop\n    artifacts: drop\n    shards:\n      max_count: 5\n      max_tokens: 4000\n").unwrap();
404/// # let git = |a: &[&str]| assert!(Command::new("git").arg("-C").arg(&root)
405/// #     .args(["-c", "user.email=t@example.com", "-c", "user.name=t"]).args(a)
406/// #     .status().unwrap().success());
407/// # git(&["init", "-q"]);
408/// # std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
409/// # git(&["add", "-A"]);
410/// # git(&["commit", "-q", "-m", "one"]);
411/// let profiles = Profiles::load(&share, &root).unwrap();
412/// let out = dir.path().join("pack");
413/// build(&plan(&root, &share, None), &root, &out).unwrap();
414/// assert!(verify(&out, &profiles, &root).passes);
415/// // a file dropped into the pack after it was built is a stray
416/// std::fs::write(out.join("99-extra.md"), "extra\n").unwrap();
417/// let v = verify(&out, &profiles, &root);
418/// assert!(v.measured && !v.passes);
419/// assert!(v.findings.iter().any(|f| f.code == "pack.stray"), "{:?}", v.findings);
420/// ```
421pub fn verify(dir: &Path, profiles: &Profiles, root: &Path) -> PackVerdict {
422    let mut v = PackVerdict {
423        measured: false,
424        reason: None,
425        dir: dir.display().to_string(),
426        profile: None,
427        commit: None,
428        files: 0,
429        sources: 0,
430        largest_tokens: 0,
431        findings: Vec::new(),
432        passes: false,
433    };
434    let manifest: Manifest = match std::fs::read_to_string(dir.join(MANIFEST))
435        .map_err(|e| format!("cannot read {MANIFEST}: {e}"))
436        .and_then(|t| serde_json::from_str(&t).map_err(|e| format!("{MANIFEST}: {e}")))
437    {
438        Ok(m) => m,
439        Err(e) => {
440            v.reason = Some(e);
441            return v;
442        }
443    };
444    v.profile = Some(manifest.profile.clone());
445    v.commit = manifest.commit.clone();
446    if manifest.schema != MANIFEST_SCHEMA {
447        v.reason = Some(format!(
448            "{MANIFEST} is '{}', not {MANIFEST_SCHEMA}",
449            manifest.schema
450        ));
451        return v;
452    }
453    let profile = match profiles.profile(Some(&manifest.profile)) {
454        Ok(p) => p.clone(),
455        Err(e) => {
456            v.reason = Some(e);
457            return v;
458        }
459    };
460    let machine = super::machine_paths(root);
461    v.measured = true;
462    let f = &mut v.findings;
463
464    let mut expected: BTreeSet<String> = manifest.shards.iter().map(|s| s.file.clone()).collect();
465    expected.insert(INDEX.into());
466    expected.insert(MANIFEST.into());
467    // the manifest was just read from this directory, so it lists; were it not to, every
468    // file the manifest names is reported missing below
469    for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
470        let name = e.file_name().to_string_lossy().into_owned();
471        if !expected.contains(&name) {
472            f.push(finding(
473                "pack.stray",
474                Some(&name),
475                format!("{name} is in the pack and not in its manifest"),
476                "remove it; only what the manifest names is sent",
477            ));
478        }
479    }
480
481    let upload = manifest.shards.len() + 1;
482    v.files = upload;
483    if let Some(l) = profile.shards {
484        if upload > l.max_count {
485            f.push(finding(
486                "pack.too_many_shards",
487                None,
488                format!(
489                    "{upload} file(s), more than the {} the profile allows",
490                    l.max_count
491                ),
492                "plan the pack again with a narrower profile",
493            ));
494        }
495    }
496
497    for sh in std::iter::once(&manifest.index).chain(&manifest.shards) {
498        let path = dir.join(&sh.file);
499        let Ok(bytes) = std::fs::read(&path) else {
500            f.push(finding(
501                "pack.missing",
502                Some(&sh.file),
503                format!("{} is named by the manifest and absent", sh.file),
504                "build the pack again; a partial pack is not sent",
505            ));
506            continue;
507        };
508        if sha256_bytes_hex(&bytes) != sh.sha256 {
509            f.push(finding(
510                "pack.tampered",
511                Some(&sh.file),
512                format!("{} does not have the digest the manifest records", sh.file),
513                "build the pack again; a changed shard is not the one that was verified",
514            ));
515        }
516        let Ok(text) = String::from_utf8(bytes) else {
517            f.push(finding(
518                "pack.not_text",
519                Some(&sh.file),
520                format!("{} is not UTF-8 text", sh.file),
521                "build the pack again",
522            ));
523            continue;
524        };
525        if text.contains('\0') {
526            f.push(finding(
527                "pack.not_text",
528                Some(&sh.file),
529                format!("{} holds a NUL byte", sh.file),
530                "build the pack again; a binary never travels in a pack",
531            ));
532        }
533        let tokens = count_tokens(&text);
534        v.largest_tokens = v.largest_tokens.max(tokens);
535        if let Some(l) = profile.shards {
536            if tokens > l.max_tokens {
537                f.push(finding(
538                    "pack.over_budget",
539                    Some(&sh.file),
540                    format!(
541                        "{} holds {tokens} tokens, more than {}",
542                        sh.file, l.max_tokens
543                    ),
544                    "plan the pack again; the reader truncates a file over its budget",
545                ));
546            }
547        }
548        if let Some(line) = super::names_machine(&text, &machine) {
549            f.push(finding(
550                "pack.leak",
551                Some(&sh.file),
552                format!("line {line} names this machine"),
553                "remove the path at its source, commit, and build the pack again",
554            ));
555        }
556        if sh.file == INDEX {
557            continue;
558        }
559        let carried = parse_blocks(&text, &sh.file, f);
560        let names: Vec<String> = carried.iter().map(|m| m.path.clone()).collect();
561        if names != sh.paths {
562            f.push(finding(
563                "pack.manifest",
564                Some(&sh.file),
565                format!(
566                    "{} carries {} file(s) and the manifest names {}, or in another order",
567                    sh.file,
568                    names.len(),
569                    sh.paths.len()
570                ),
571                "build the pack again",
572            ));
573        }
574        for m in carried {
575            v.sources += 1;
576            let forbidden = if m.mode == "160000" {
577                Some("a gitlink (a nested repository or worktree)")
578            } else if m.mode == "120000" {
579                Some("a symbolic link")
580            } else if profile.artifacts == "drop" && profiles.is_artifact(&m.path) {
581                Some("an artifact path")
582            } else if profile.binary == "drop" && profiles.has_binary_extension(&m.path) {
583                Some("a binary extension")
584            } else {
585                None
586            };
587            if let Some(what) = forbidden {
588                f.push(finding(
589                    "pack.forbidden_path",
590                    Some(&m.path),
591                    format!("{} is {what}, which the profile never carries", m.path),
592                    "build the pack again with this executable; a pack written by hand is not verified",
593                ));
594            }
595        }
596    }
597    v.passes = v.findings.is_empty();
598    v
599}
600
601/// Every block of a shard, its content checked against its marker's digest.
602fn parse_blocks(text: &str, file: &str, f: &mut Vec<PackFinding>) -> Vec<Marker> {
603    let lines: Vec<&str> = text.split('\n').collect();
604    let mut out = Vec::new();
605    let mut i = 0;
606    while i < lines.len() {
607        let Some(rest) = lines[i].strip_prefix(MARK) else {
608            i += 1;
609            continue;
610        };
611        let json = rest.strip_suffix(" -->").unwrap_or(rest);
612        let Ok(marker) = serde_json::from_str::<Marker>(json) else {
613            f.push(finding(
614                "pack.manifest",
615                Some(file),
616                format!("{file}:{}: a file marker that does not parse", i + 1),
617                "build the pack again",
618            ));
619            i += 1;
620            continue;
621        };
622        // marker, heading, blank, opening fence
623        let open = i + 3;
624        let fence: String = lines
625            .get(open)
626            .map(|l| l.chars().take_while(|c| *c == '`').collect())
627            .unwrap_or_default();
628        let close =
629            (open + 1..lines.len()).find(|&j| lines[j] == fence && lines.get(j + 1) == Some(&END));
630        let Some(close) = close.filter(|_| fence.len() >= 3) else {
631            f.push(finding(
632                "pack.manifest",
633                Some(&marker.path),
634                format!("{file}: the block of {} is not closed", marker.path),
635                "build the pack again",
636            ));
637            i += 1;
638            continue;
639        };
640        let mut content = lines[open + 1..close].join("\n");
641        if close > open + 1 && marker.eol {
642            content.push('\n');
643        }
644        if sha256_bytes_hex(content.as_bytes()) != marker.sha256
645            || content.len() as u64 != marker.bytes
646        {
647            f.push(finding(
648                "pack.tampered",
649                Some(&marker.path),
650                format!(
651                    "{file}: the content of {} does not have its recorded digest",
652                    marker.path
653                ),
654                "build the pack again",
655            ));
656        }
657        out.push(marker);
658        i = close + 2;
659    }
660    out
661}
662
663/// How many blocks a text holds that parse and check, for the module's tests.
664#[cfg(test)]
665pub(super) fn tests_parse(text: &str, f: &mut Vec<PackFinding>) -> usize {
666    parse_blocks(text, "test", f).len()
667}