Skip to main content

upcloud_api/
guard.rs

1//! **The test every consuming repository runs**: nothing outside the files it
2//! names spells the provider or builds an UpCloud API path.
3//!
4//! Lane T3's `nothing_outside_this_module_spells_the_provider` was the
5//! template: `grow_cloud.rs:17` carried its own `const API =
6//! "https://api.upcloud.com/1.3"` for months while the crate's variable was
7//! believed to cover it. A trait nobody is forced through is a suggestion. This
8//! scan is what forces it — in each repository, over its own sources.
9//!
10//! What counts: a CODE line (not a `//` comment) before the file's first
11//! `#[cfg(test)]` that contains one of [`PATTERNS`]. A test module may say
12//! `api.upcloud.com` (a redaction test must), and a doc may discuss it.
13
14use std::path::{Path, PathBuf};
15
16/// The spellings that mean "this line talks to UpCloud by itself": the host,
17/// the versioned root, and the path shapes only an API client builds.
18pub const PATTERNS: &[&str] = &[
19    "api.upcloud.com",
20    "upcloud.com/1.3",
21    "\"/1.3",
22    "/1.3/server",
23    "/1.3/storage",
24    "/firewall_rule",
25    "/cdrom/eject",
26    "/storage/attach",
27    "/storage/detach",
28    "/storage/private",
29    "\"/server/{",
30    "\"/storage/{",
31];
32
33/// Every offending line under `roots` (recursively, `*.rs` only), as
34/// `path:line: text`. `allowed` names files by their path SUFFIX
35/// (`"src/upcloud_api.rs"`) whose job it is to spell the provider.
36pub fn offenders(roots: &[&Path], allowed: &[&str]) -> Vec<String> {
37    let mut out = Vec::new();
38    let mut stack: Vec<PathBuf> = roots.iter().map(|p| p.to_path_buf()).collect();
39    while let Some(p) = stack.pop() {
40        if p.is_dir() {
41            // A build tree is not a source tree.
42            if p.file_name().map(|n| n == "target" || n == ".git").unwrap_or(false) {
43                continue;
44            }
45            if let Ok(rd) = std::fs::read_dir(&p) {
46                stack.extend(rd.flatten().map(|e| e.path()));
47            }
48            continue;
49        }
50        if p.extension().and_then(|s| s.to_str()) != Some("rs") {
51            continue;
52        }
53        let shown = p.to_string_lossy().replace('\\', "/");
54        if allowed.iter().any(|a| shown.ends_with(a)) {
55            continue;
56        }
57        let Ok(text) = std::fs::read_to_string(&p) else { continue };
58        out.extend(scan(&shown, &text));
59    }
60    out.sort();
61    out
62}
63
64/// One file's offending lines.
65pub fn scan(name: &str, text: &str) -> Vec<String> {
66    let mut out = Vec::new();
67    for (i, line) in text.lines().enumerate() {
68        let t = line.trim_start();
69        if t.starts_with("#[cfg(test)]") {
70            break;
71        }
72        if t.starts_with("//") {
73            continue;
74        }
75        let code = without_labels(line);
76        if PATTERNS.iter().any(|p| code.contains(p)) {
77            out.push(format!("{name}:{}: {}", i + 1, line.trim()));
78        }
79    }
80    out
81}
82
83/// A piece of text that NAMES a call — `"GET /storage/private"`, `"PLAN ONLY.
84/// Reads: GET /1.3/server/{uuid}, …"`, a plan line `2 PUT  /1.3/storage/{} …`
85/// — is a description for a log line, a plan or a refusal, not a request: it
86/// tells an operator which call. The line is cut at every `"` and each piece
87/// that carries an HTTP method followed by a path is dropped before matching,
88/// so describing a call is allowed and building one is not. (Cutting at quotes
89/// rather than parsing literals is deliberate: a multi-line string's inner
90/// lines have no opening quote, and they are descriptions too.)
91fn without_labels(line: &str) -> String {
92    line.split('"').filter(|piece| !names_a_call(piece)).collect::<Vec<_>>().join("\"")
93}
94
95/// `GET /`, `PUT  /` (a plan's column alignment), `POST /`, `DELETE /`.
96fn names_a_call(piece: &str) -> bool {
97    ["GET", "POST", "PUT", "DELETE"].iter().any(|m| {
98        piece.match_indices(m).any(|(i, _)| {
99            let before_ok = i == 0 || !piece.as_bytes()[i - 1].is_ascii_alphanumeric();
100            let after = &piece[i + m.len()..];
101            let spaced = after.trim_start_matches(' ');
102            before_ok && spaced.len() < after.len() && spaced.starts_with('/')
103        })
104    })
105}
106
107/// Panic with every offender named — the one-line body of each repository's
108/// guard test.
109pub fn assert_none(roots: &[&Path], allowed: &[&str]) {
110    let o = offenders(roots, allowed);
111    assert!(
112        o.is_empty(),
113        "these lines talk to UpCloud by themselves instead of through `upcloud_api::UpCloudApi` — so a run \
114         aimed at the fake does not test them, and one of them is how a mock run reaches the account:\n{}",
115        o.join("\n")
116    );
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn a_hardcoded_base_is_caught_and_a_comment_or_a_test_is_not() {
125        let src = "// api.upcloud.com is discussed here\n\
126                   const API: &str = \"https://api.upcloud.com/1.3\";\n\
127                   let u = format!(\"{API}/server/{uuid}/stop\");\n\
128                   #[cfg(test)]\n\
129                   const T: &str = \"https://api.upcloud.com/1.3\";\n";
130        let o = scan("x.rs", src);
131        assert_eq!(o.len(), 1, "{o:?}");
132        assert!(o[0].starts_with("x.rs:2:"), "{o:?}");
133    }
134
135    #[test]
136    fn a_path_built_without_the_host_is_caught_too() {
137        // The shape `upcloud.rs` had: a base from somewhere, a path spelled here.
138        for l in ["let u = format!(\"{}/storage/private\", base);", "get(\"/1.3/account\")", "self.get(&format!(\"/server/{uuid}\"))"] {
139            assert_eq!(scan("y.rs", l).len(), 1, "{l}");
140        }
141    }
142
143    #[test]
144    fn a_label_that_names_a_call_is_not_a_call() {
145        assert!(scan("z.rs", "must(\"GET /storage/private\", self.api.storages_private())?;").is_empty());
146        assert!(scan("z.rs", "log(\"POST /server/{uuid}/cdrom/eject\"); get(\"/storage/private\")").len() == 1, "a label does not excuse the rest of the line");
147        assert!(scan("z.rs", "p.push(format!(\"PLAN ONLY. Reads: GET /1.3/server/{u}, GET /1.3/account.\"));").is_empty());
148        assert!(scan("z.rs", "   2 PUT  /1.3/storage/{} {{size {target_gb}}} → wait").is_empty(), "a plan column");
149        assert_eq!(scan("z.rs", "(\"POST\", format!(\"/server/{uuid}/stop\"), body)").len(), 1, "a bare method word beside a built path is a request");
150    }
151
152    /// This crate's own sources pass with exactly the files whose job it is.
153    #[test]
154    fn this_crate_spells_the_provider_only_where_it_belongs() {
155        let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
156        assert_none(&[&src], &["src/lib.rs", "src/wire.rs", "src/over.rs", "src/mock_door.rs", "src/guard.rs"]);
157        // …and the scan is not vacuous: without the allowance it finds them.
158        assert!(!offenders(&[&src], &["src/guard.rs"]).is_empty());
159    }
160}