1use 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
37pub 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
118pub(super) fn block_tokens(entry: &IndexEntry, text: &str) -> u64 {
120 count_tokens(&block(entry, text))
121}
122
123pub(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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
149pub struct ManifestShard {
150 pub file: String,
152 pub sha256: String,
154 pub tokens: u64,
156 pub paths: Vec<String>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
162pub struct Manifest {
163 pub schema: String,
165 pub profile: String,
167 pub commit: Option<String>,
169 pub index_matches_head: bool,
171 pub tokenizer: String,
173 pub index: ManifestShard,
175 pub shards: Vec<ManifestShard>,
177 pub dropped: BTreeMap<String, usize>,
179}
180
181pub(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
259pub(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 count_tokens(&header(&sh.file, 99, 99, commit, profile, &sh)) + count_tokens(first) + 8
273}
274
275pub(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 let json = serde_json::to_string_pretty(&manifest).unwrap_or_default();
323 put(out, MANIFEST, &(json + "\n"))
324}
325
326fn 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
361pub struct PackVerdict {
362 pub measured: bool,
364 pub reason: Option<String>,
366 pub dir: String,
368 pub profile: Option<String>,
370 pub commit: Option<String>,
372 pub files: usize,
374 pub sources: usize,
376 pub largest_tokens: u64,
378 pub findings: Vec<PackFinding>,
380 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
388pub 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 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
601fn 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 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#[cfg(test)]
665pub(super) fn tests_parse(text: &str, f: &mut Vec<PackFinding>) -> usize {
666 parse_blocks(text, "test", f).len()
667}