1use std::collections::BTreeMap;
10use std::path::Path;
11
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15use super::discover::GENERATED_ROOT;
16use super::model::{Availability, SurfaceKind, Topology};
17
18#[derive(
20 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
21)]
22#[serde(rename_all = "kebab-case")]
23#[schemars(rename = "SurfaceFindingSeverity")]
25pub enum Severity {
30 Warning,
32 Error,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
38#[schemars(rename = "SurfaceFinding")]
39pub struct Finding {
40 pub severity: Severity,
42 pub rule: String,
44 pub surface: String,
46 pub message: String,
48 pub remedy: String,
50}
51
52pub fn validate(topology: &Topology, root: &Path, artifacts: Artifacts) -> Vec<Finding> {
73 let mut findings = Vec::new();
74 findings.extend(identities(topology));
75 findings.extend(mounts(topology));
76 findings.extend(shadowing(topology, root));
77 findings.extend(namespaces(topology));
78 findings.extend(roots(topology, root, artifacts));
79 findings.sort_by(|a, b| {
83 b.severity
84 .cmp(&a.severity)
85 .then_with(|| crate::order::natural_cmp(&a.rule, &b.rule))
86 .then_with(|| crate::order::natural_cmp(&a.surface, &b.surface))
87 });
88 findings
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum Artifacts {
94 Required,
96 Ignore,
98}
99
100fn identities(topology: &Topology) -> Vec<Finding> {
103 let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
104 for surface in &topology.surfaces {
105 *seen.entry(surface.id.as_str()).or_default() += 1;
106 }
107 seen.into_iter()
108 .filter(|(_, n)| *n > 1)
109 .map(|(id, n)| Finding {
110 severity: Severity::Error,
111 rule: "surface.duplicate-id".into(),
112 surface: id.to_string(),
113 message: format!("{n} surfaces claim the id '{id}'"),
114 remedy: "give each producer its own id; the id is what --only and --exclude select"
115 .into(),
116 })
117 .collect()
118}
119
120fn namespaces(topology: &Topology) -> Vec<Finding> {
150 let mut findings = Vec::new();
151 for reservation in super::discover::reserved() {
152 let Ok(mount) = super::model::Mount::parse(reservation.path) else {
153 continue;
154 };
155 for surface in topology
156 .surfaces
157 .iter()
158 .filter(|s| s.availability.is_served())
159 {
160 if surface.mount == mount && surface.id != reservation.owner {
161 findings.push(Finding {
162 severity: Severity::Error,
163 rule: "surface.reserved-namespace".into(),
164 surface: surface.id.clone(),
165 message: format!(
166 "'{}' is mounted at {mount}, which is reserved for the {} and belongs to '{}'",
167 surface.id, reservation.role, reservation.owner
168 ),
169 remedy: format!(
170 "mount '{}' somewhere else; {mount} means one thing and '{}' is what means it",
171 surface.id, reservation.owner
172 ),
173 });
174 }
175 if surface.id == reservation.owner && surface.mount != mount {
176 findings.push(Finding {
177 severity: Severity::Error,
178 rule: "surface.reserved-namespace".into(),
179 surface: surface.id.clone(),
180 message: format!(
181 "'{}' is the {} and is mounted at {} rather than at its reserved {mount}",
182 surface.id, reservation.role, surface.mount
183 ),
184 remedy: format!(
185 "a reserved mount moves in the constant that declares it, so that everything reading it moves too; leave '{}' at {mount}",
186 surface.id
187 ),
188 });
189 }
190 }
191 }
192 findings
193}
194
195fn shadowing(topology: &Topology, root: &Path) -> Vec<Finding> {
209 let mut findings = Vec::new();
210 let Some(application) = topology
211 .surfaces
212 .iter()
213 .find(|s| s.mount.is_root() && s.kind == SurfaceKind::StaticDirectory)
214 else {
215 return findings;
216 };
217 let Some(artifact) = application.artifact.as_ref() else {
218 return findings;
219 };
220 let base = root.join(artifact);
221 for surface in &topology.surfaces {
222 if surface.mount.is_root() || surface.kind == SurfaceKind::StaticDirectory {
223 continue;
224 }
225 let relative = surface.mount.relative();
226 if relative.is_empty() {
227 continue;
228 }
229 let target = base.join(&relative);
230 if !target.exists() {
231 continue;
232 }
233 let pages = if target.is_dir() {
234 count_pages(&target)
235 } else {
236 1
237 };
238 findings.push(Finding {
239 severity: Severity::Warning,
240 rule: "surface.shadows-application".into(),
241 surface: format!("{} over {}", surface.id, application.id),
242 message: format!(
243 "{} answers {} itself, and {} has {} page(s) there ({}/{})",
244 surface.id,
245 surface.mount,
246 application.id,
247 pages,
248 artifact.display(),
249 relative
250 ),
251 remedy: format!(
252 "move {} to a path the application does not use, or move those pages: while \
253 both claim {}, the executable answers and the pages are unreachable",
254 surface.id, surface.mount
255 ),
256 });
257 }
258 findings
259}
260
261fn count_pages(dir: &Path) -> usize {
263 let Ok(entries) = std::fs::read_dir(dir) else {
264 return 0;
265 };
266 let mut total = 0;
267 for entry in entries.flatten() {
268 let path = entry.path();
269 if path.is_dir() {
270 total += count_pages(&path);
271 } else if path.extension().is_some_and(|e| e == "html") {
272 total += 1;
273 }
274 }
275 total
276}
277
278fn mounts(topology: &Topology) -> Vec<Finding> {
292 let mut findings = Vec::new();
293 for world in World::ALL {
294 let surfaces: Vec<&super::model::Surface> = topology
295 .surfaces
296 .iter()
297 .filter(|s| world.holds(s))
298 .collect();
299 for (i, outer) in surfaces.iter().enumerate() {
300 for inner in surfaces.iter().skip(i + 1) {
301 if outer.mount == inner.mount {
302 findings.push(Finding {
303 severity: Severity::Error,
304 rule: "surface.mount-collision".into(),
305 surface: format!("{} and {}", outer.id, inner.id),
306 message: format!(
307 "both claim {} when {} ({} from {}, {} from {})",
308 outer.mount,
309 world.describe(),
310 outer.id,
311 provenance_of(outer, "mount"),
312 inner.id,
313 provenance_of(inner, "mount")
314 ),
315 remedy: "one path has one owner: change one producer's declared mount"
316 .into(),
317 });
318 continue;
319 }
320 let (over, under) = if outer.mount.contains(&inner.mount) {
321 (outer, inner)
322 } else if inner.mount.contains(&outer.mount) {
323 (inner, outer)
324 } else {
325 continue;
326 };
327 if over.mount.is_root() {
328 continue; }
330 findings.push(Finding {
331 severity: Severity::Error,
332 rule: "surface.nested-mount".into(),
333 surface: format!("{} inside {}", under.id, over.id),
334 message: format!(
335 "{} is mounted at {} inside {}, which owns {} and everything under it when {}",
336 under.id,
337 under.mount,
338 over.id,
339 over.mount,
340 world.describe()
341 ),
342 remedy: format!(
343 "move {} outside {}, or let {} produce it as part of its own output",
344 under.id,
345 over.mount.prefix(),
346 over.id
347 ),
348 });
349 }
350 }
351 }
352 findings.sort_by(|a, b| {
355 crate::order::natural_cmp(&a.rule, &b.rule)
356 .then_with(|| crate::order::natural_cmp(&a.surface, &b.surface))
357 .then_with(|| b.severity.cmp(&a.severity))
358 });
359 findings.dedup_by(|a, b| a.rule == b.rule && a.surface == b.surface);
360 findings
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366enum World {
367 Served,
369 Published,
371}
372
373impl World {
374 const ALL: [World; 2] = [World::Served, World::Published];
375
376 fn holds(self, surface: &super::model::Surface) -> bool {
377 match self {
378 World::Served => surface.availability.is_served(),
379 World::Published => surface.availability.is_published(),
380 }
381 }
382
383 fn describe(self) -> &'static str {
384 match self {
385 World::Served => "a process serves them",
386 World::Published => "a publication holds them",
387 }
388 }
389}
390
391fn roots(topology: &Topology, root: &Path, artifacts: Artifacts) -> Vec<Finding> {
394 let mut findings = Vec::new();
395 for surface in &topology.surfaces {
396 match surface.kind {
397 SurfaceKind::StaticDirectory => {}
398 SurfaceKind::NativeRoute | SurfaceKind::Redirect => {
399 if surface.artifact.is_some() {
400 findings.push(Finding {
401 severity: Severity::Warning,
402 rule: "surface.artifact-on-dynamic".into(),
403 surface: surface.id.clone(),
404 message: "a route the executable answers itself names a directory".into(),
405 remedy: "drop the artifact, or declare the surface static".into(),
406 });
407 }
408 continue;
409 }
410 }
411 let Some(artifact) = surface.artifact.as_ref() else {
412 findings.push(Finding {
413 severity: Severity::Error,
414 rule: "surface.missing-artifact-path".into(),
415 surface: surface.id.clone(),
416 message: "a static surface names no directory".into(),
417 remedy: "declare the directory the producer writes".into(),
418 });
419 continue;
420 };
421 let text = artifact.to_string_lossy().replace('\\', "/");
422 if artifact.is_absolute() || text.split('/').any(|s| s == "..") {
423 findings.push(Finding {
424 severity: Severity::Error,
425 rule: "surface.artifact-escapes".into(),
426 surface: surface.id.clone(),
427 message: format!("the directory '{text}' leaves the repository"),
428 remedy: "a surface is served from a repository-relative generated directory".into(),
429 });
430 continue;
431 }
432 if !(text.starts_with(GENERATED_ROOT) || text.starts_with("site/")) {
433 findings.push(Finding {
434 severity: Severity::Error,
435 rule: "surface.artifact-outside-generated-root".into(),
436 surface: surface.id.clone(),
437 message: format!(
438 "the directory '{text}' is neither under {GENERATED_ROOT}/ nor the site's output"
439 ),
440 remedy: format!(
441 "write the producer's output into {GENERATED_ROOT}/{}/",
442 surface.id
443 ),
444 });
445 continue;
446 }
447 if artifacts == Artifacts::Ignore {
448 continue;
449 }
450 let dir = root.join(artifact);
451 if !dir.is_dir() {
452 findings.push(Finding {
453 severity: Severity::Error,
454 rule: "surface.artifact-absent".into(),
455 surface: surface.id.clone(),
456 message: format!("'{text}' does not exist"),
457 remedy: format!("run the producer: {}", surface.producer),
458 });
459 continue;
460 }
461 if let Some(index) = surface.index.as_ref() {
462 if !dir.join(index).is_file() && surface.availability != Availability::ServedOnly {
463 findings.push(Finding {
464 severity: Severity::Error,
465 rule: "surface.index-absent".into(),
466 surface: surface.id.clone(),
467 message: format!(
468 "'{text}/{index}' does not exist, so {} answers with nothing",
469 surface.mount
470 ),
471 remedy: format!("the producer writes {index}, or declares another index"),
472 });
473 }
474 }
475 }
476 findings
477}
478
479fn provenance_of(surface: &super::model::Surface, field: &str) -> String {
480 surface
481 .provenance
482 .get(field)
483 .map(|p| p.to_string())
484 .unwrap_or_else(|| "an unrecorded source".into())
485}
486
487pub fn blocking(findings: &[Finding]) -> bool {
489 findings.iter().any(|f| f.severity == Severity::Error)
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use crate::web::model::{Availability, Category, Mount, Surface, Visibility};
496 use std::collections::BTreeMap;
497
498 fn deployment(id: &str) -> Surface {
501 Surface {
502 availability: Availability::PublishedOnly,
503 ..surface(id, "/", Some("site/public"))
504 }
505 }
506
507 fn surface(id: &str, mount: &str, artifact: Option<&str>) -> Surface {
508 Surface {
509 id: id.into(),
510 title: id.into(),
511 category: Category::Report,
512 visibility: Visibility::Public,
513 kind: if artifact.is_some() {
514 SurfaceKind::StaticDirectory
515 } else {
516 SurfaceKind::NativeRoute
517 },
518 mount: Mount::parse(mount).unwrap(),
519 producer: format!("producer of {id}"),
520 feature: None,
521 artifact: artifact.map(Into::into),
522 index: artifact.map(|_| "index.html".to_string()),
523 availability: if artifact.is_some() {
524 Availability::Both
525 } else {
526 Availability::ServedOnly
527 },
528 built_from: None,
529 provenance: BTreeMap::new(),
530 }
531 }
532
533 #[test]
534 fn a_clean_topology_has_nothing_to_say() {
535 let t = Topology::new(vec![
536 deployment("app"),
537 surface("tests", "/tests", Some("target/web/tests")),
538 surface("api", "/api/v1", None),
539 ]);
540 let findings = validate(&t, Path::new("/nonexistent"), Artifacts::Ignore);
541 assert!(findings.is_empty(), "{findings:?}");
542 }
543
544 #[test]
545 fn the_root_application_may_hold_every_other_surface() {
546 let t = Topology::new(vec![
547 deployment("app"),
548 surface("tests", "/tests", Some("target/web/tests")),
549 ]);
550 assert!(validate(&t, Path::new("/nonexistent"), Artifacts::Ignore).is_empty());
551 }
552
553 #[test]
554 fn nesting_under_anything_else_is_refused_with_both_names() {
555 let t = Topology::new(vec![
556 surface("tests", "/tests", Some("target/web/tests")),
557 surface("ui", "/tests/ui", Some("target/web/ui")),
558 ]);
559 let findings = validate(&t, Path::new("/nonexistent"), Artifacts::Ignore);
560 let nested = findings
561 .iter()
562 .find(|f| f.rule == "surface.nested-mount")
563 .expect("nesting is a finding");
564 assert!(nested.message.contains("/tests/ui"), "{}", nested.message);
565 assert!(nested.remedy.contains("tests"), "{}", nested.remedy);
566 }
567
568 #[test]
569 fn a_reserved_mount_held_by_the_wrong_producer_is_refused_with_both_names() {
570 let mut surfaces = crate::web::discover::native_all();
571 let swagger = surfaces.iter_mut().find(|s| s.id == "swagger").unwrap();
572 swagger.mount = Mount::parse(crate::web::discover::DOCS_MOUNT).unwrap();
573 let findings = validate(
574 &Topology::new(surfaces),
575 Path::new("/nonexistent"),
576 Artifacts::Ignore,
577 );
578 let finding = findings
579 .iter()
580 .find(|f| f.rule == "surface.reserved-namespace")
581 .expect("the viewer taking the documentation's name is a finding");
582 assert!(finding.message.contains("/docs"), "{}", finding.message);
583 assert!(finding.message.contains("docs"), "{}", finding.message);
584 assert!(blocking(&findings));
585 }
586
587 #[test]
588 fn a_reserved_owner_that_wandered_off_its_mount_is_refused() {
589 let mut surfaces = crate::web::discover::native_all();
590 let swagger = surfaces.iter_mut().find(|s| s.id == "swagger").unwrap();
591 swagger.mount = Mount::parse("/api-viewer").unwrap();
592 let findings = validate(
593 &Topology::new(surfaces),
594 Path::new("/nonexistent"),
595 Artifacts::Ignore,
596 );
597 assert!(findings
598 .iter()
599 .any(|f| f.rule == "surface.reserved-namespace"));
600 }
601
602 #[test]
603 fn a_reserved_mount_nobody_claims_is_not_a_finding() {
604 let surfaces: Vec<Surface> = crate::web::discover::native_all()
607 .into_iter()
608 .filter(|s| s.id == "openapi")
609 .collect();
610 let findings = validate(
611 &Topology::new(surfaces),
612 Path::new("/nonexistent"),
613 Artifacts::Ignore,
614 );
615 assert!(findings.is_empty(), "{findings:?}");
616 }
617
618 #[test]
619 fn the_declared_topology_of_this_repository_holds_every_reserved_name() {
620 let mut surfaces = crate::web::discover::native_all();
621 surfaces.extend(crate::web::discover::application(Path::new("/nonexistent")));
622 let findings = validate(
625 &Topology::new(surfaces),
626 Path::new("/nonexistent"),
627 Artifacts::Ignore,
628 );
629 assert!(findings.is_empty(), "{findings:?}");
630 }
631
632 #[test]
637 fn a_second_surface_at_the_rustdoc_mount_is_a_duplicate_canonical_route() {
638 use crate::web::discover::{self, Runtime, RUSTDOC, RUSTDOC_MOUNT};
639 let tmp = tempfile::tempdir().unwrap();
640 let krate = tmp.path().join(crate::capability::model::CRATE_DIR);
641 std::fs::create_dir_all(krate.join("src")).unwrap();
642 std::fs::write(krate.join("Cargo.toml"), "").unwrap();
643 std::fs::write(krate.join("src/lib.rs"), "//! x\n").unwrap();
644 let copy = tmp.path().join(GENERATED_ROOT).join("rust-api");
645 std::fs::create_dir_all(©).unwrap();
646 std::fs::write(
647 copy.join(discover::DECLARATION_FILE),
648 r#"{"schema":"web-surface/v1","id":"rust-api","mount":"/rustdoc"}"#,
649 )
650 .unwrap();
651
652 let topology = discover::discover(tmp.path(), Runtime::full()).unwrap();
653 let findings = validate(&topology, tmp.path(), Artifacts::Ignore);
654 assert!(blocking(&findings), "{findings:?}");
655 let collision = findings
656 .iter()
657 .find(|f| f.rule == "surface.mount-collision")
658 .expect("two owners of one path is a collision");
659 assert!(collision.surface.contains(RUSTDOC), "{}", collision.surface);
660 assert!(
661 collision.surface.contains("rust-api"),
662 "{}",
663 collision.surface
664 );
665 assert!(
667 collision
668 .message
669 .contains("target/web/rust-api/surface.json"),
670 "{}",
671 collision.message
672 );
673 let reserved = findings
674 .iter()
675 .find(|f| f.rule == "surface.reserved-namespace" && f.surface == "rust-api")
676 .expect("the copy is refused by name, not only as half of a collision");
677 assert!(
678 reserved.message.contains(RUSTDOC_MOUNT),
679 "{}",
680 reserved.message
681 );
682
683 std::fs::remove_dir_all(&krate).unwrap();
686 let orphaned = discover::discover(tmp.path(), Runtime::full()).unwrap();
687 assert!(orphaned.get(RUSTDOC).is_none());
688 let findings = validate(&orphaned, tmp.path(), Artifacts::Ignore);
689 assert!(
690 findings
691 .iter()
692 .any(|f| f.rule == "surface.reserved-namespace" && f.surface == "rust-api"),
693 "{findings:?}"
694 );
695 }
696
697 #[test]
698 fn a_surface_inside_the_rustdoc_mount_is_refused_as_nesting() {
699 let t = Topology::new(vec![
700 deployment("app"),
701 surface("rustdoc", "/rustdoc", Some("target/web/rustdoc")),
702 surface("search", "/rustdoc/search", Some("target/web/search")),
703 ]);
704 let findings = validate(&t, Path::new("/nonexistent"), Artifacts::Ignore);
705 assert!(
706 findings
707 .iter()
708 .any(|f| f.rule == "surface.nested-mount" && f.surface == "search inside rustdoc"),
709 "{findings:?}"
710 );
711 }
712
713 #[test]
714 fn an_artifact_outside_the_generated_root_is_refused() {
715 let t = Topology::new(vec![surface("rogue", "/rogue", Some("docs"))]);
716 let findings = validate(&t, Path::new("/nonexistent"), Artifacts::Ignore);
717 assert!(findings
718 .iter()
719 .any(|f| f.rule == "surface.artifact-outside-generated-root"));
720 }
721
722 #[test]
723 fn an_artifact_that_walks_out_of_the_repository_is_refused() {
724 let t = Topology::new(vec![surface(
725 "rogue",
726 "/rogue",
727 Some("target/web/../../etc"),
728 )]);
729 let findings = validate(&t, Path::new("/nonexistent"), Artifacts::Ignore);
730 assert!(findings
731 .iter()
732 .any(|f| f.rule == "surface.artifact-escapes"));
733 }
734
735 #[test]
736 fn a_missing_directory_is_a_finding_only_when_the_artifact_is_required() {
737 let t = Topology::new(vec![surface("tests", "/tests", Some("target/web/tests"))]);
738 assert!(validate(&t, Path::new("/nonexistent"), Artifacts::Ignore).is_empty());
739 let required = validate(&t, Path::new("/nonexistent"), Artifacts::Required);
740 assert!(required.iter().any(|f| f.rule == "surface.artifact-absent"));
741 assert!(blocking(&required));
742 }
743
744 #[test]
745 fn adding_an_unrelated_surface_does_not_disturb_the_others() {
746 let before = Topology::new(vec![
747 deployment("app"),
748 surface("tests", "/tests", Some("target/web/tests")),
749 ]);
750 let after = Topology::new(vec![
751 deployment("app"),
752 surface("tests", "/tests", Some("target/web/tests")),
753 surface(
754 "example-report",
755 "/example-report",
756 Some("target/web/example-report"),
757 ),
758 ]);
759 assert!(validate(&after, Path::new("/nonexistent"), Artifacts::Ignore).is_empty());
760 assert_eq!(before.owner("/tests/x").unwrap().id, "tests");
761 assert_eq!(after.owner("/tests/x").unwrap().id, "tests");
762 assert_eq!(
763 after.owner("/example-report/i").unwrap().id,
764 "example-report"
765 );
766 }
767}