use std::path::{Path, PathBuf};
pub const PATTERNS: &[&str] = &[
"api.upcloud.com",
"upcloud.com/1.3",
"\"/1.3",
"/1.3/server",
"/1.3/storage",
"/firewall_rule",
"/cdrom/eject",
"/storage/attach",
"/storage/detach",
"/storage/private",
"\"/server/{",
"\"/storage/{",
];
pub fn offenders(roots: &[&Path], allowed: &[&str]) -> Vec<String> {
let mut out = Vec::new();
let mut stack: Vec<PathBuf> = roots.iter().map(|p| p.to_path_buf()).collect();
while let Some(p) = stack.pop() {
if p.is_dir() {
if p.file_name().map(|n| n == "target" || n == ".git").unwrap_or(false) {
continue;
}
if let Ok(rd) = std::fs::read_dir(&p) {
stack.extend(rd.flatten().map(|e| e.path()));
}
continue;
}
if p.extension().and_then(|s| s.to_str()) != Some("rs") {
continue;
}
let shown = p.to_string_lossy().replace('\\', "/");
if allowed.iter().any(|a| shown.ends_with(a)) {
continue;
}
let Ok(text) = std::fs::read_to_string(&p) else { continue };
out.extend(scan(&shown, &text));
}
out.sort();
out
}
pub fn scan(name: &str, text: &str) -> Vec<String> {
let mut out = Vec::new();
for (i, line) in text.lines().enumerate() {
let t = line.trim_start();
if t.starts_with("#[cfg(test)]") {
break;
}
if t.starts_with("//") {
continue;
}
let code = without_labels(line);
if PATTERNS.iter().any(|p| code.contains(p)) {
out.push(format!("{name}:{}: {}", i + 1, line.trim()));
}
}
out
}
fn without_labels(line: &str) -> String {
line.split('"').filter(|piece| !names_a_call(piece)).collect::<Vec<_>>().join("\"")
}
fn names_a_call(piece: &str) -> bool {
["GET", "POST", "PUT", "DELETE"].iter().any(|m| {
piece.match_indices(m).any(|(i, _)| {
let before_ok = i == 0 || !piece.as_bytes()[i - 1].is_ascii_alphanumeric();
let after = &piece[i + m.len()..];
let spaced = after.trim_start_matches(' ');
before_ok && spaced.len() < after.len() && spaced.starts_with('/')
})
})
}
pub fn assert_none(roots: &[&Path], allowed: &[&str]) {
let o = offenders(roots, allowed);
assert!(
o.is_empty(),
"these lines talk to UpCloud by themselves instead of through `upcloud_api::UpCloudApi` — so a run \
aimed at the fake does not test them, and one of them is how a mock run reaches the account:\n{}",
o.join("\n")
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_hardcoded_base_is_caught_and_a_comment_or_a_test_is_not() {
let src = "// api.upcloud.com is discussed here\n\
const API: &str = \"https://api.upcloud.com/1.3\";\n\
let u = format!(\"{API}/server/{uuid}/stop\");\n\
#[cfg(test)]\n\
const T: &str = \"https://api.upcloud.com/1.3\";\n";
let o = scan("x.rs", src);
assert_eq!(o.len(), 1, "{o:?}");
assert!(o[0].starts_with("x.rs:2:"), "{o:?}");
}
#[test]
fn a_path_built_without_the_host_is_caught_too() {
for l in ["let u = format!(\"{}/storage/private\", base);", "get(\"/1.3/account\")", "self.get(&format!(\"/server/{uuid}\"))"] {
assert_eq!(scan("y.rs", l).len(), 1, "{l}");
}
}
#[test]
fn a_label_that_names_a_call_is_not_a_call() {
assert!(scan("z.rs", "must(\"GET /storage/private\", self.api.storages_private())?;").is_empty());
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");
assert!(scan("z.rs", "p.push(format!(\"PLAN ONLY. Reads: GET /1.3/server/{u}, GET /1.3/account.\"));").is_empty());
assert!(scan("z.rs", " 2 PUT /1.3/storage/{} {{size {target_gb}}} → wait").is_empty(), "a plan column");
assert_eq!(scan("z.rs", "(\"POST\", format!(\"/server/{uuid}/stop\"), body)").len(), 1, "a bare method word beside a built path is a request");
}
#[test]
fn this_crate_spells_the_provider_only_where_it_belongs() {
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
assert_none(&[&src], &["src/lib.rs", "src/wire.rs", "src/over.rs", "src/mock_door.rs", "src/guard.rs"]);
assert!(!offenders(&[&src], &["src/guard.rs"]).is_empty());
}
}