1use std::collections::{HashMap, HashSet};
6use std::path::{Path, PathBuf};
7
8use serde_json::Value as JValue;
9
10use crate::bundle::{Bundle, Scan, Visit};
11use crate::error::{Issue, Report, Result, Stats, code};
12use crate::meta::{Entry, Kind, ManifestPolicy, Meta, Policies, ShaPolicy};
13use crate::util::{self, SCHEMA_DIR};
14
15pub fn validate(bundle: &Bundle) -> Result<Report> {
17 let scan = bundle.scan()?;
18 let mut checker = Checker::new(bundle, &scan);
19 checker.run();
20 checker.check_revision_history();
21 Ok(checker.report)
22}
23
24struct Checker<'a> {
26 bundle: &'a Bundle,
27 scan: &'a Scan,
28 report: Report,
29 policies: Policies,
30}
31
32impl<'a> Checker<'a> {
33 fn new(bundle: &'a Bundle, scan: &'a Scan) -> Self {
34 let policies = scan
35 .root_index
36 .and_then(|i| scan.visits[i].meta.as_ref())
37 .map(|m| m.policies.clone())
38 .unwrap_or_default();
39 let mut report = Report {
40 bundle: bundle.name(),
41 issues: Vec::new(),
42 stats: Stats::default(),
43 };
44 report.issues.extend(scan.issues.iter().cloned());
45 Self {
46 bundle,
47 scan,
48 report,
49 policies,
50 }
51 }
52
53 fn strict(&self) -> bool {
56 self.policies.manifest == ManifestPolicy::Strict
57 }
58
59 fn warn(&mut self, code: &'static str, path: impl Into<String>, msg: impl Into<String>) {
60 self.report.push(Issue::warn(code, path, msg));
61 }
62
63 fn err(&mut self, code: &'static str, path: impl Into<String>, msg: impl Into<String>) {
64 self.report.push(Issue::error(code, path, msg));
65 }
66
67 fn manifest_issue(
69 &mut self,
70 e_code: &'static str,
71 w_code: &'static str,
72 path: impl Into<String>,
73 msg: impl Into<String>,
74 ) {
75 let path = path.into();
76 let msg = msg.into();
77 if self.strict() {
78 self.err(e_code, path, msg);
79 } else {
80 self.warn(w_code, path, msg);
81 }
82 }
83
84 fn run(&mut self) {
87 let scan = self.scan;
88 let name = self.bundle.name();
89 if !name.ends_with(".str") {
90 self.warn(
91 code::BUNDLE_SUFFIX,
92 ".",
93 format!("根目录名 {name:?} 未以 `.str` 结尾"),
94 );
95 }
96
97 let Some(root_idx) = scan.root_index else {
98 return;
99 };
100 self.report.stats.depth = scan.max_depth;
101
102 for v in &scan.visits {
103 match v.depth {
104 0 => {}
105 1 => self.report.stats.nodes += 1,
106 _ => self.report.stats.branches += 1,
107 }
108 if let Some(m) = &v.meta {
109 self.report.stats.entries += m.entries.len();
110 }
111 }
112
113 let dups: Vec<(String, String, usize)> = scan
115 .by_id
116 .iter()
117 .filter(|(_, idxs)| idxs.len() > 1)
118 .map(|(id, idxs)| {
119 (
120 id.clone(),
121 scan.visits[idxs[0]].rel.clone(),
122 idxs.len(),
123 )
124 })
125 .collect();
126 for (id, path, n) in dups {
127 self.err(code::ID_DUP, path, format!("id `{id}` 在 {n} 处重复出现"));
128 }
129
130 if let Some(m) = scan.visits[root_idx].meta.as_ref() {
132 if m.kind != Some(Kind::Root) {
133 self.err(
134 code::KIND_DEPTH,
135 ".",
136 format!("深度 0 的 `kind` 必须是 `root`,实为 {:?}", m.kind_raw),
137 );
138 }
139 if m.name.is_none() {
140 self.err(code::SCHEMA_FIELD, ".", "root 缺少必填字段 `name`");
141 }
142 }
143
144 for i in 0..scan.visits.len() {
145 self.check_branch(i);
146 }
147
148 self.check_ref_cycles();
149 self.check_meta_schemas();
150 }
151
152 fn check_branch(&mut self, idx: usize) {
154 let scan = self.scan;
155 let v: &Visit = &scan.visits[idx];
156 let rel = v.rel.clone();
157 let depth = v.depth;
158 let Some(meta) = v.meta.as_ref() else {
159 return; };
161
162 let expected_kind = match depth {
164 0 => Kind::Root,
165 1 => Kind::Node,
166 _ => Kind::Branch,
167 };
168 if let Some(k) = meta.kind {
169 if k != expected_kind {
170 self.err(
171 code::KIND_DEPTH,
172 rel.clone(),
173 format!(
174 "深度 {depth} 的 `kind` 必须是 `{}`,实为 `{}`",
175 expected_kind.as_str(),
176 k.as_str()
177 ),
178 );
179 }
180 }
181
182 if let Some(ver) = meta.str_version {
183 if ver != crate::STR_MAJOR {
184 self.err(
185 code::SPEC_UNSUPPORTED,
186 rel.clone(),
187 format!("`str` = {ver},本实现只支持主版本 {}", crate::STR_MAJOR),
188 );
189 }
190 }
191
192 let dir_name = v
194 .dir
195 .file_name()
196 .map(|s| s.to_string_lossy().to_string())
197 .unwrap_or_default();
198 if depth >= 1 {
199 if !util::is_uuid(&dir_name) {
200 self.err(
201 code::ID_NOT_UUID,
202 rel.clone(),
203 format!("分支目录名 {dir_name:?} 不是合法 UUID"),
204 );
205 } else if let Some(ver) = util::uuid_version(&dir_name) {
206 if ver != self.policies.id_version {
207 self.err(
208 code::ID_VERSION,
209 rel.clone(),
210 format!(
211 "目录名 UUID 版本为 {ver},`policies.id_version` 要求 {}",
212 self.policies.id_version
213 ),
214 );
215 }
216 }
217 if let Some(id) = meta.id.as_deref() {
218 if id != dir_name {
219 self.err(
220 code::ID_MISMATCH,
221 rel.clone(),
222 format!("`id` = {id:?} 与目录名 {dir_name:?} 不一致"),
223 );
224 }
225 }
226 }
227
228 if depth > self.policies.max_depth {
230 self.err(
231 code::DEPTH_EXCEEDED,
232 rel.clone(),
233 format!(
234 "深度 {depth} 超过 `policies.max_depth` = {}",
235 self.policies.max_depth
236 ),
237 );
238 } else if depth > self.policies.deep_tree_warn {
239 self.warn(
240 code::DEEP_TREE,
241 rel.clone(),
242 format!(
243 "深度 {depth} 超过 `policies.deep_tree_warn` = {},建议拆分",
244 self.policies.deep_tree_warn
245 ),
246 );
247 }
248
249 if depth >= 1 && meta.summary.is_none() {
250 self.warn(
251 code::NO_SUMMARY,
252 rel.clone(),
253 "建议补充 `summary`(AI 检索依据)",
254 );
255 }
256 if depth >= 1 && meta.r#type.is_none() {
257 self.warn(code::NO_TYPE, rel.clone(), "建议补充 `type`");
258 }
259
260 for issue in meta.revision_issues(&rel) {
261 self.report.push(issue);
262 }
263
264 let own_id = meta.id.clone();
266 let refs = meta.refs.clone();
267 let mut bad_targets: Vec<(usize, String, String)> = Vec::new();
268 for (n, r) in refs.iter().enumerate() {
269 if Some(r.target.as_str()) == own_id.as_deref() {
270 self.err(
271 code::REF_SELF,
272 format!("{rel} #refs[{n}]"),
273 "关联线指向自身",
274 );
275 continue;
276 }
277 if !r.target.is_empty() && scan.resolve(&r.target).is_none() {
278 bad_targets.push((n, r.target.clone(), rel.clone()));
279 }
280 }
281 for (n, target, rel) in bad_targets {
282 self.err(
283 code::REF_NO_TARGET,
284 format!("{rel} #refs[{n}]"),
285 format!("`target` = {target:?} 无法在本 bundle 内解析到任何分支"),
286 );
287 }
288
289 self.check_manifest(idx);
291 }
292
293 fn check_manifest(&mut self, idx: usize) {
295 let scan = self.scan;
296 let bundle = self.bundle;
297 let v: &Visit = &scan.visits[idx];
298 let rel = v.rel.clone();
299 let dir = v.dir.clone();
300 let meta: &Meta = match v.meta.as_ref() {
301 Some(m) => m,
302 None => return,
303 };
304 let entries: Vec<Entry> = meta.entries.clone();
305
306 let mut seen: HashSet<String> = HashSet::new();
308 let mut dups: Vec<String> = Vec::new();
309 for e in &entries {
310 if !seen.insert(e.path.clone()) {
311 dups.push(e.path.clone());
312 }
313 }
314 for p in dups {
315 self.err(
316 code::MANIFEST_DUP,
317 format!("{rel} #entries"),
318 format!("`entries[].path` = {p:?} 重复"),
319 );
320 }
321
322 let actual = bundle.list_names(&dir).unwrap_or_default();
323
324 for (name, is_dir) in &actual {
326 let ep = format!("{rel}/{name}");
327 if util::is_meta_file(name) || util::is_lock_file(name) {
328 continue; }
330 if util::is_reserved_name(name) {
331 let known = name == crate::util::SCHEMA_DIR || name == crate::util::CACHE_DIR;
337 let declared_here = entries.iter().any(|e| e.path == *name);
338 if !known && (*is_dir || declared_here) {
339 self.err(
340 code::RESERVED_NAME,
341 ep.clone(),
342 "业务条目不得以 `._` 开头(`._` 为格式保留命名空间)",
343 );
344 }
345 continue;
346 }
347 if util::is_os_noise(name) {
348 continue; }
350 if util::is_other_dotfile(name) {
351 self.warn(
352 code::DOTFILE,
353 ep.clone(),
354 "出现非 `._meta` / `.lock` 的点文件(不计入清单要求)",
355 );
356 continue;
357 }
358 let declared_here = entries.iter().any(|e| e.path == *name);
361 if v.depth == 0 && !util::is_uuid(name) && !declared_here {
362 self.warn(
363 code::ROOT_STRAY,
364 ep.clone(),
365 "ROOT 下出现既非分支目录(UUID 命名)又未登记的条目",
366 );
367 }
368 match entries.iter().find(|e| e.path == *name) {
369 None => {
370 let what = if *is_dir { "目录" } else { "文件" };
371 self.manifest_issue(
372 code::MANIFEST_MISSING,
373 code::MANIFEST_MISSING_W,
374 ep.clone(),
375 format!("磁盘存在{what}但 `entries` 未登记"),
376 );
377 }
378 Some(e) => {
379 let e = e.clone();
380 self.check_entry(&dir, v.depth, meta, &e, &ep, *is_dir);
381 }
382 }
383 }
384
385 let mut ghosts: Vec<(String, bool)> = Vec::new();
387 for e in &entries {
388 let path: PathBuf = dir.join(&e.path);
389 if path.exists() {
390 continue;
391 }
392 if e.optional {
393 self.warn(
394 code::OPTIONAL_MISSING,
395 format!("{rel}/{}", e.path),
396 "`optional = true` 的条目当前缺失(允许)",
397 );
398 } else {
399 ghosts.push((format!("{rel}/{}", e.path), false));
400 }
401 }
402 for (ep, _) in ghosts {
403 self.manifest_issue(
404 code::MANIFEST_GHOST,
405 code::MANIFEST_GHOST_W,
406 ep,
407 "`entries` 已登记但磁盘不存在",
408 );
409 }
410 }
411
412 fn check_entry(
414 &mut self,
415 dir: &Path,
416 parent_depth: usize,
417 meta: &Meta,
418 e: &Entry,
419 ep: &str,
420 is_dir: bool,
421 ) {
422 let bundle = self.bundle;
423 let path: PathBuf = dir.join(&e.path);
424
425 match e.role.as_str() {
426 "node" | "branch" | "dir" => {
427 if !is_dir {
428 self.err(
429 code::ENTRY_ROLE_DEPTH,
430 ep,
431 format!("`role = {:?}` 要求目录,但磁盘上是文件", e.role),
432 );
433 return;
434 }
435 let dir_name = path
436 .file_name()
437 .map(|s| s.to_string_lossy().to_string())
438 .unwrap_or_default();
439 let child_has_meta = bundle.has_meta(&path);
440 if e.role == "dir" {
441 if util::is_sub_bundle(&dir_name) {
442 self.err(
443 code::ENTRY_ROLE_DEPTH,
444 ep,
445 "该目录名以 `.str` 结尾(独立子 bundle),应登记为 `role = \"bundle\"`",
446 );
447 return;
448 }
449 if child_has_meta {
450 self.err(
451 code::ENTRY_ROLE_DEPTH,
452 ep,
453 "该子目录含 `._meta`(已是分支),却被登记为 `role = \"dir\"`",
454 );
455 }
456 return;
457 }
458 if !child_has_meta {
459 self.err(code::META_MISSING, ep, "登记为分支但目录内缺少 `._meta`");
460 return;
461 }
462 let want_node = e.role == "node";
463 if parent_depth == 0 && !want_node {
464 self.err(
465 code::ENTRY_ROLE_DEPTH,
466 ep,
467 "ROOT 的直接子分支必须是 `role = \"node\"`",
468 );
469 } else if parent_depth >= 1 && want_node {
470 self.err(
471 code::ENTRY_ROLE_DEPTH,
472 ep,
473 "深度 ≥1 的子分支必须是 `role = \"branch\"`",
474 );
475 }
476 if let Some(id) = &e.id {
477 let dir_name = path
478 .file_name()
479 .map(|s| s.to_string_lossy().to_string())
480 .unwrap_or_default();
481 if *id != dir_name {
482 self.err(
483 code::ENTRY_ID_MISMATCH,
484 ep,
485 format!("`entries[].id` = {id:?} 与子目录名 {dir_name:?} 不一致"),
486 );
487 }
488 }
489 let child_kind = match bundle.read_meta(&path) {
491 Ok(crate::meta::MetaLoad::Ok(child, _)) => child.kind,
492 _ => None,
493 };
494 if let Some(k) = child_kind {
495 let expect = if want_node { Kind::Node } else { Kind::Branch };
496 if k != expect {
497 self.err(
498 code::ENTRY_ROLE_DEPTH,
499 ep,
500 format!(
501 "父级登记 `role = {:?}`,但子分支 `kind` = `{}`",
502 e.role,
503 k.as_str()
504 ),
505 );
506 }
507 }
508 }
509 "payload" | "asset" => {
510 if is_dir {
511 self.err(
512 code::ENTRY_ROLE_DEPTH,
513 ep,
514 format!("`role = {:?}` 要求文件,但磁盘上是目录", e.role),
515 );
516 return;
517 }
518 self.check_file_digest(e, ep, &path);
519 if e.role == "payload" {
520 self.check_payload_schema(meta, e, ep, &path);
521 }
522 }
523 "schema" | "cache" => {
524 if !is_dir {
525 self.err(
526 code::ENTRY_ROLE_DEPTH,
527 ep,
528 format!("`role = {:?}` 要求目录", e.role),
529 );
530 }
531 }
532 "bundle" => {
533 let dir_name = path
535 .file_name()
536 .map(|s| s.to_string_lossy().to_string())
537 .unwrap_or_default();
538 if !is_dir {
539 self.err(
540 code::ENTRY_ROLE_DEPTH,
541 ep,
542 "`role = \"bundle\"` 要求目录",
543 );
544 } else if !util::is_sub_bundle(&dir_name) {
545 self.err(
546 code::ENTRY_ROLE_DEPTH,
547 ep,
548 format!("`role = \"bundle\"` 要求目录名以 `.str` 结尾,实为 {dir_name:?}"),
549 );
550 }
551 }
552 _ => {}
553 }
554 }
555
556 fn check_file_digest(&mut self, e: &Entry, ep: &str, path: &Path) {
558 if self.policies.sha256 == ShaPolicy::Required && (e.size.is_none() || e.sha256.is_none()) {
559 self.err(
560 code::MANIFEST_DIGEST_MISSING,
561 ep,
562 "`policies.sha256 = \"required\"`:文件类条目必须同时提供 `size` 与 `sha256`",
563 );
564 }
565 let Ok(md) = std::fs::metadata(path) else {
566 return;
567 };
568 let real_size = md.len();
569 if let Some(s) = e.size {
570 if s < 0 || s as u64 != real_size {
571 self.manifest_issue(
572 code::MANIFEST_HASH,
573 code::MANIFEST_HASH_W,
574 ep,
575 format!("`size` = {s} 与实际 {real_size} 不符"),
576 );
577 }
578 }
579 if self.policies.sha256 != ShaPolicy::Off {
580 if let Some(declared) = e.sha256.clone() {
581 match util::sha256_file(path) {
582 Ok(real) if real == declared => {}
583 Ok(real) => {
584 let d = &declared[..declared.len().min(12)];
585 let r = &real[..12];
586 self.manifest_issue(
587 code::MANIFEST_HASH,
588 code::MANIFEST_HASH_W,
589 ep,
590 format!("`sha256` 不符:声明 {d}…,实际 {r}…"),
591 );
592 }
593 Err(err) => {
594 self.manifest_issue(
595 code::MANIFEST_HASH,
596 code::MANIFEST_HASH_W,
597 ep,
598 format!("无法计算摘要:{err}"),
599 );
600 }
601 }
602 }
603 }
604 if real_size > self.policies.large_asset_bytes {
605 self.warn(
606 code::LARGE_ASSET,
607 ep,
608 format!(
609 "文件 {real_size} 字节,超过 `large_asset_bytes` = {}",
610 self.policies.large_asset_bytes
611 ),
612 );
613 }
614 }
615
616 fn check_revision_history(&mut self) {
621 let baseline = crate::baseline::load(self.bundle);
622 if baseline.branches.is_empty() {
623 return;
624 }
625 let mut found: Vec<(String, String, i64, i64, String)> = Vec::new();
626 for v in &self.scan.visits {
627 let Some(meta) = v.meta.as_ref() else {
628 continue;
629 };
630 let Some(snap) = baseline.branches.get(&v.rel) else {
631 continue;
632 };
633 let (Some(rev), Some(updated)) = (meta.revision, meta.updated_at.as_deref()) else {
634 continue;
635 };
636 let changed = match (
637 util::parse_rfc3339(&snap.updated_at),
638 util::parse_rfc3339(updated),
639 ) {
640 (Some(a), Some(b)) => a != b,
641 _ => snap.updated_at != updated,
642 };
643 if changed && rev <= snap.revision {
644 found.push((
645 v.rel.clone(),
646 snap.updated_at.clone(),
647 snap.revision,
648 rev,
649 updated.to_string(),
650 ));
651 }
652 }
653 for (rel, was, old_rev, new_rev, now) in found {
654 self.err(
655 code::REVISION_STALE,
656 rel,
657 format!(
658 "`updated_at` 已由 {was} 变为 {now},但 `revision` 未前进({old_rev} → {new_rev});\
659 规范 §7.2 要求每次写入同时 +1 并刷新时间"
660 ),
661 );
662 }
663 }
664
665 fn check_ref_cycles(&mut self) {
667 let scan = self.scan;
668 let n = scan.visits.len();
669 let mut edges: Vec<Vec<usize>> = vec![Vec::new(); n];
670 for (i, v) in scan.visits.iter().enumerate() {
671 if let Some(m) = &v.meta {
672 for r in &m.refs {
673 if let Some(j) = scan.resolve(&r.target) {
674 edges[i].push(j);
675 }
676 }
677 }
678 }
679
680 let mut color = vec![0u8; n]; let mut hits: Vec<String> = Vec::new();
682 for start in 0..n {
683 if color[start] != 0 {
684 continue;
685 }
686 let mut stack: Vec<(usize, usize)> = vec![(start, 0)];
687 color[start] = 1;
688 while !stack.is_empty() {
689 let top = stack.len() - 1;
690 let (node, k) = stack[top];
691 if k < edges[node].len() {
692 let next = edges[node][k];
693 stack[top].1 += 1;
694 match color[next] {
695 0 => {
696 color[next] = 1;
697 stack.push((next, 0));
698 }
699 1 => hits.push(scan.visits[next].rel.clone()),
700 _ => {}
701 }
702 } else {
703 color[node] = 2;
704 stack.pop();
705 }
706 }
707 }
708 hits.sort();
709 hits.dedup();
710 for path in hits {
711 self.err(
712 code::REF_CYCLE,
713 path,
714 "`refs` 关联图成环(追踪终点回到链上已有分支)",
715 );
716 }
717 }
718
719 fn check_meta_schemas(&mut self) {
721 let scan = self.scan;
722 let bundle = self.bundle;
723 let items: Vec<(String, JValue, &'static str)> = scan
724 .visits
725 .iter()
726 .filter_map(|v| {
727 let m = v.meta.as_ref()?;
728 let name: &'static str = match v.depth {
729 0 => "root-meta.schema.json",
730 1 => "node-meta.schema.json",
731 _ => "branch-meta.schema.json",
732 };
733 Some((v.rel.clone(), m.to_json(), name))
734 })
735 .collect();
736
737 for (rel, instance, name) in items {
738 let schema = load_meta_schema(bundle, name);
739 let Ok(validator) = jsonschema::validator_for(&schema) else {
740 continue;
741 };
742 let msgs: Vec<String> = validator
743 .iter_errors(&instance)
744 .map(|e| format!("{e}(位于 {})", e.instance_path()))
745 .collect();
746 for m in msgs {
747 self.err(code::SCHEMA_FAIL, rel.clone(), m);
748 }
749 }
750 }
751
752 fn check_payload_schema(&mut self, meta: &Meta, e: &Entry, ep: &str, path: &Path) {
754 let Some(schema_ref) = e.schema.clone().or_else(|| meta.schema.clone()) else {
755 return;
756 };
757 let schema_path = self.bundle.root.join(&schema_ref);
758 let Ok(schema_bytes) = std::fs::read(&schema_path) else {
759 return;
760 };
761 let Ok(schema) = serde_json::from_slice::<JValue>(&schema_bytes) else {
762 return;
763 };
764 let Ok(validator) = jsonschema::validator_for(&schema) else {
765 return;
766 };
767 let Ok(bytes) = std::fs::read(path) else {
768 return;
769 };
770 let Ok(instance) = serde_json::from_slice::<JValue>(&bytes) else {
771 return; };
773 let msgs: Vec<String> = validator
774 .iter_errors(&instance)
775 .map(|err| format!("{err}(位于 {})", err.instance_path()))
776 .collect();
777 for m in msgs {
778 self.err(
779 code::SCHEMA_FAIL,
780 ep,
781 format!("payload 不满足 `{schema_ref}`:{m}"),
782 );
783 }
784 }
785}
786
787fn load_meta_schema(bundle: &Bundle, name: &str) -> JValue {
789 let local = bundle.root.join(SCHEMA_DIR).join(name);
790 if local.is_file() {
791 if let Ok(bytes) = std::fs::read(&local) {
792 if let Ok(v) = serde_json::from_slice::<JValue>(&bytes) {
793 return v;
794 }
795 }
796 }
797 crate::EMBEDDED_SCHEMAS
798 .iter()
799 .find(|(n, _)| *n == name)
800 .and_then(|(_, t)| serde_json::from_str(t).ok())
801 .unwrap_or_else(|| JValue::Object(Default::default()))
802}
803
804pub fn count_dir_entries(dir: &Path) -> usize {
806 std::fs::read_dir(dir)
807 .map(|rd| rd.flatten().filter(|e| !util::is_meta_file(&e.file_name().to_string_lossy())).count())
808 .unwrap_or(0)
809}
810
811pub fn dir_child_count(dir: &Path) -> Option<i64> {
813 std::fs::read_dir(dir).ok().map(|rd| {
814 rd.flatten()
815 .filter(|e| !util::is_meta_file(&e.file_name().to_string_lossy()))
816 .count() as i64
817 })
818}
819
820pub fn real_entries(dir: &Path) -> Vec<(String, bool)> {
822 let mut out = Vec::new();
823 let Ok(rd) = std::fs::read_dir(dir) else {
824 return out;
825 };
826 for ent in rd.flatten() {
827 let name = ent.file_name().to_string_lossy().to_string();
828 if util::is_meta_file(&name) || util::is_os_noise(&name) || util::is_lock_file(&name) {
829 continue;
830 }
831 let is_dir = ent.file_type().map(|t| t.is_dir()).unwrap_or(false);
832 out.push((name, is_dir));
833 }
834 out.sort();
835 out
836}
837
838pub fn id_index(scan: &Scan) -> HashMap<String, String> {
840 scan.by_id
841 .iter()
842 .filter_map(|(id, idxs)| idxs.first().map(|i| (id.clone(), scan.visits[*i].rel.clone())))
843 .collect()
844}