1use std::path::{Path, PathBuf};
15
16pub 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
33pub 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 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
64pub 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
83fn without_labels(line: &str) -> String {
89 let mut out = String::with_capacity(line.len());
90 let mut rest = line;
91 while let Some(i) = rest.find('"') {
92 out.push_str(&rest[..i]);
93 let after = &rest[i + 1..];
94 let is_label = ["GET /", "POST /", "PUT /", "DELETE /"].iter().any(|m| after.starts_with(m));
95 match after.find('"') {
96 Some(j) if is_label => {
97 out.push_str("\"\"");
98 rest = &after[j + 1..];
99 }
100 _ => {
101 out.push('"');
102 rest = after;
103 }
104 }
105 }
106 out.push_str(rest);
107 out
108}
109
110pub fn assert_none(roots: &[&Path], allowed: &[&str]) {
113 let o = offenders(roots, allowed);
114 assert!(
115 o.is_empty(),
116 "these lines talk to UpCloud by themselves instead of through `upcloud_api::UpCloudApi` — so a run \
117 aimed at the fake does not test them, and one of them is how a mock run reaches the account:\n{}",
118 o.join("\n")
119 );
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn a_hardcoded_base_is_caught_and_a_comment_or_a_test_is_not() {
128 let src = "// api.upcloud.com is discussed here\n\
129 const API: &str = \"https://api.upcloud.com/1.3\";\n\
130 let u = format!(\"{API}/server/{uuid}/stop\");\n\
131 #[cfg(test)]\n\
132 const T: &str = \"https://api.upcloud.com/1.3\";\n";
133 let o = scan("x.rs", src);
134 assert_eq!(o.len(), 1, "{o:?}");
135 assert!(o[0].starts_with("x.rs:2:"), "{o:?}");
136 }
137
138 #[test]
139 fn a_path_built_without_the_host_is_caught_too() {
140 for l in ["let u = format!(\"{}/storage/private\", base);", "get(\"/1.3/account\")", "self.get(&format!(\"/server/{uuid}\"))"] {
142 assert_eq!(scan("y.rs", l).len(), 1, "{l}");
143 }
144 }
145
146 #[test]
147 fn a_label_that_names_a_call_is_not_a_call() {
148 assert!(scan("z.rs", "must(\"GET /storage/private\", self.api.storages_private())?;").is_empty());
149 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");
150 }
151
152 #[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/guard.rs"]);
157 assert!(!offenders(&[&src], &["src/guard.rs"]).is_empty());
159 }
160}