Skip to main content

majordomus_cli/web/
validate.rs

1//! Validation: the invariants a topology must satisfy before anything serves or publishes it.
2//!
3//! A collision here is an error, never an ordering accident: two surfaces claiming one path
4//! is refused at the point it is discovered rather than resolved by whichever router
5//! happened to insert its route first. Every finding names the surface, the value, where the
6//! value came from and what to do about it, because a convention-heavy system that reports
7//! "invalid topology" is a system nobody can maintain.
8
9use 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/// How much a finding matters.
19#[derive(
20    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
21)]
22#[serde(rename_all = "kebab-case")]
23// `Severity` alone is the index's diagnostic severity; a topology's finding has its own
24#[schemars(rename = "SurfaceFindingSeverity")]
25// Declared least first, like `model::Severity`, so `Ord` means the same thing in both:
26// greater is worse, and every comparator that puts the worst first reads `b.cmp(a)`.
27// Declared the other way round, the two enums made identical intent look like opposite
28// code, and a change that reconciled the two comparators would have inverted one report.
29pub enum Severity {
30    /// Worth reading; serving is still coherent.
31    Warning,
32    /// The topology may not be served or published in this state.
33    Error,
34}
35
36/// One thing wrong with a topology, said so a person can fix it without reading this file.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
38#[schemars(rename = "SurfaceFinding")]
39pub struct Finding {
40    /// How much it matters.
41    pub severity: Severity,
42    /// The rule that produced it, stable enough to grep for.
43    pub rule: String,
44    /// The surface it is about.
45    pub surface: String,
46    /// What is wrong.
47    pub message: String,
48    /// What to do about it.
49    pub remedy: String,
50}
51
52/// Every invariant, over one topology.
53///
54/// `artifacts` says whether a static surface's directory must already exist: a validation
55/// before a build has nothing to find, and one before serving or publishing has everything
56/// to lose by finding nothing.
57///
58/// ```
59/// use majordomus_cli::web::{model::*, validate};
60/// # use std::collections::BTreeMap;
61/// # fn s(id: &str, mount: &str) -> Surface {
62/// #     Surface { id: id.into(), title: id.into(), category: Category::Report,
63/// #         visibility: Visibility::Public, kind: SurfaceKind::StaticDirectory,
64/// #         mount: Mount::parse(mount).unwrap(), producer: "t".into(), feature: None,
65/// #         artifact: Some("target/web/x".into()), index: Some("index.html".into()),
66/// #         availability: Availability::Both, built_from: None, provenance: BTreeMap::new() }
67/// # }
68/// let clash = Topology::new(vec![s("a", "/tests"), s("b", "/tests")]);
69/// let findings = validate::validate(&clash, std::path::Path::new("."), validate::Artifacts::Ignore);
70/// assert!(findings.iter().any(|f| f.rule == "surface.mount-collision"));
71/// ```
72pub 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    // The worst first, then the canonical order over what is left. The same shape the
80    // other two finding lists use (`why::findings`, `product::findings`), and now the same
81    // direction: `b.cmp(a)` on a severity whose greater variant is the worse one.
82    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/// Whether a static surface's generated directory must exist for the topology to be valid.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum Artifacts {
94    /// The directory must be there, with its index: what serving and publishing need.
95    Required,
96    /// The directory may be missing: what a validation before the build can say.
97    Ignore,
98}
99
100/// Two surfaces may not share an identity: the id is the selector, and an ambiguous
101/// selector silently selects the wrong thing.
102fn 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
120/// The reserved mounts: a fixed meaning held by the surface that is supposed to hold it.
121///
122/// A collision already refuses two surfaces claiming one path, and while both the
123/// documentation and the viewer exist that is enough to keep them apart. It is not enough
124/// when one of them is gone: remove the documentation surface and nothing stops the viewer
125/// taking `/docs`, which is exactly the state this repository was in before. So the
126/// reservation is checked against the producer that must own it, not only against whoever
127/// else wants it.
128///
129/// The reservations are about what a *process serves*, which is the world these names have
130/// a meaning in; a publication's `/` belongs to the site as it is deployed and always has.
131/// So only served surfaces are checked, for the same reason mount ownership is checked per
132/// world: a rule applied across both would report a conflict that cannot happen.
133///
134/// Absence is not a violation. A process that serves no Cockpit, a build made without a
135/// site, a topology narrowed to one selector — each is a legitimate subset, and a
136/// reservation says who owns a mount *if* it is claimed and *if* its owner is present, never
137/// that every reserved mount must exist.
138///
139/// ```
140/// use majordomus_cli::web::{discover, validate, Mount, Topology};
141/// let mut surfaces = discover::native_all();
142/// // the viewer helps itself to the documentation's name, with the documentation absent
143/// let swagger = surfaces.iter_mut().find(|s| s.id == "swagger").unwrap();
144/// swagger.mount = Mount::parse(discover::DOCS_MOUNT).unwrap();
145/// let findings = validate::validate(
146///     &Topology::new(surfaces), std::path::Path::new("."), validate::Artifacts::Ignore);
147/// assert!(findings.iter().any(|f| f.rule == "surface.reserved-namespace"), "{findings:?}");
148/// ```
149fn 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
195/// A route the executable answers itself, mounted over pages the root application has.
196///
197/// The nesting rule exempts the root application, because answering what nothing else claims
198/// is its whole job. That exemption is right for the topology and blind to one thing: the
199/// application does not only answer what nothing else claims — it has *files* at particular
200/// paths, and a native route mounted at one of them takes every page beneath it. The
201/// topology stays resolvable and the pages stop existing, silently, for anybody using the
202/// running executable rather than the published site.
203///
204/// So this is measured rather than reasoned about: the application's own output is asked
205/// whether it has something at the route. A warning, not an error, because which of the two
206/// should move is intent — a person decides whether the route or the section is renamed —
207/// and refusing to serve until they decide would be worse than telling them.
208fn 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
261/// How many rendered pages a directory of the application holds, at any depth.
262fn 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
278/// Mount ownership: one owner per path, in each of the two worlds a surface can live in.
279///
280/// A surface owns its mount and everything under it. Two surfaces claiming one mount is a
281/// collision, and a surface mounted inside another's subtree is a shadowing conflict —
282/// whichever answered would depend on the router rather than on the declaration. The single
283/// exception is a root surface, which exists precisely to answer what nothing else claims.
284///
285/// The check is per world, because a topology has two. What a running process serves and
286/// what a publication contains are different sets of surfaces, and a path claimed in one is
287/// not claimed in the other: the site as it is deployed owns `/` of a publication, and the
288/// home page owns `/` of a process, and they never meet. Checking them together would
289/// report a conflict that cannot happen; checking them apart is what makes the two mounts
290/// declarable at all.
291fn 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; // a root surface answers what nothing else claims: that is its job
329                }
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    // Grouped so that `dedup_by` sees duplicates adjacent, with the worst of a duplicated
353    // pair first so that it is the one that survives. The caller sorts the result again.
354    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/// The two worlds a mount can be claimed in. A surface lives in one, the other, or both,
364/// and a path is only contested by surfaces that share a world.
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366enum World {
367    /// Answered by a running process.
368    Served,
369    /// Present in a publication's files.
370    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
391/// What a static surface's directory must be: inside the generated root or the site's own
392/// output, present when it is needed, and carrying the index its mount answers with.
393fn 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
487/// Does this set of findings stop a topology from being served or published?
488pub 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    /// The deployment's root: a static surface at `/` that a publication holds and no
499    /// process serves, which is what the site as it is deployed actually is.
500    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        // a process that serves no Cockpit, a build with no site, a topology narrowed by a
605        // selector: each is a legitimate subset, and absence is not a violation
606        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        // the fixture has no site, so the two site surfaces are absent; what is present
623        // must still sit where it is reserved to sit
624        let findings = validate(
625            &Topology::new(surfaces),
626            Path::new("/nonexistent"),
627            Artifacts::Ignore,
628        );
629        assert!(findings.is_empty(), "{findings:?}");
630    }
631
632    /// The failure mode the reservation exists for: a second producer publishing the
633    /// crate's reference at its canonical mount — a copy of the rustdoc under another id,
634    /// written by somebody who thought it should also be "a report". Two answers for one
635    /// path, and whichever the router consulted first would be the reference.
636    #[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(&copy).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        // each side says where its claim came from, so the reader knows which to move
666        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        // and the reservation holds with the owner gone: in a repository whose crate was
684        // moved, nothing else may help itself to the reference's name
685        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}