Skip to main content

origin_xtask/
validate.rs

1//! Machine-checked architecture rules.
2//!
3//! Documented rules rot. These are the subset of `ARCHITECTURE.md` that can be
4//! verified mechanically, so a violation fails CI instead of surviving review.
5
6use std::collections::HashMap;
7use std::fmt::Write as _;
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12/// Product names that must never appear inside the platform crates (rule 2).
13const PRODUCT_NAMES: &[&str] = &["contoso", "fabrikam", "northwind"];
14
15/// Capability grants that hand the frontend far more than it needs (rule 14).
16const FORBIDDEN_PERMISSIONS: &[&str] = &[
17    "fs:allow-all",
18    "fs:default",
19    "shell:allow-execute",
20    "shell:allow-spawn",
21    "shell:default",
22];
23
24pub fn run(root: &Path) -> Result<(), String> {
25    let root = root.to_path_buf();
26    let mut failures = Vec::new();
27
28    failures.extend(check_layer_dependencies(&root)?);
29    failures.extend(check_no_product_names(&root)?);
30    failures.extend(check_invoke_is_confined(&root)?);
31    failures.extend(check_capabilities(&root)?);
32    failures.extend(check_commands_exist(&root)?);
33    failures.extend(check_manifest_matches_tauri_config(&root)?);
34
35    if failures.is_empty() {
36        println!("architecture rules: ok");
37        return Ok(());
38    }
39
40    let mut report = String::from("architecture violations:\n");
41    for failure in &failures {
42        let _ = writeln!(report, "  - {failure}");
43    }
44    Err(report)
45}
46
47/// Rule 1, 5: dependencies point downwards only.
48fn check_layer_dependencies(root: &Path) -> Result<Vec<String>, String> {
49    let mut failures = Vec::new();
50    let dependencies_by_package = workspace_dependencies(root)?;
51
52    for (layer, forbidden) in [
53        ("crates", &["tauri", "adapters", "host", "examples"][..]),
54        ("adapters", &["adapters", "examples"][..]),
55        ("host", &["examples"][..]),
56    ] {
57        for manifest in crates_in(&root.join(layer))? {
58            let package = manifest
59                .parent()
60                .and_then(|path| path.file_name())
61                .map(|name| name.to_string_lossy().into_owned())
62                .unwrap_or_default();
63            let dependencies = dependencies_by_package
64                .get(&package)
65                .cloned()
66                .unwrap_or_default();
67
68            for dependency in &dependencies {
69                // `adapters` may of course depend on themselves being in that layer;
70                // what is forbidden is depending on a *different* adapter.
71                if layer == "adapters" && dependency == &package {
72                    continue;
73                }
74
75                let violates = match layer {
76                    "crates" => {
77                        dependency.starts_with("tauri")
78                            || is_workspace_member_of(root, dependency, forbidden)
79                    }
80                    _ => is_workspace_member_of(root, dependency, forbidden),
81                };
82
83                if violates {
84                    failures.push(format!(
85                        "{layer}/{package} depends on `{dependency}` — {layer} must not \
86                         depend on {}",
87                        forbidden.join(", ")
88                    ));
89                }
90            }
91        }
92    }
93
94    Ok(failures)
95}
96
97/// Rule 2: the platform never knows which product it serves.
98///
99/// `origin-xtask` is exempt: it is a build tool, not runtime platform code, and it has
100/// to name the products in order to check for them.
101fn check_no_product_names(root: &Path) -> Result<Vec<String>, String> {
102    let mut failures = Vec::new();
103    let tooling = root.join("crates").join("origin-xtask");
104
105    for file in rust_sources(&root.join("crates"))? {
106        if file.starts_with(&tooling) {
107            continue;
108        }
109
110        let contents = read(&file)?.to_lowercase();
111        for product in PRODUCT_NAMES {
112            // The rule itself is quoted in ARCHITECTURE.md and in ADR text; only code
113            // is checked here.
114            if contents.contains(product) {
115                failures.push(format!(
116                    "{} mentions the product `{product}` — platform code must not know \
117                     its consumers",
118                    relative(root, &file)
119                ));
120            }
121        }
122    }
123
124    Ok(failures)
125}
126
127/// Rule 15: only `@origin/client` speaks Tauri IPC.
128///
129/// Scans the whole project, not a fixed list of directories: in a derivative the
130/// frontend lives wherever that product put it, and a rule that silently skips it is
131/// worse than no rule.
132fn check_invoke_is_confined(root: &Path) -> Result<Vec<String>, String> {
133    let mut failures = Vec::new();
134    // The one package allowed to know the transport. In a derivative it arrives
135    // through node_modules, which is never scanned.
136    let allowed = root.join("frontend").join("client");
137
138    for file in frontend_sources(root)? {
139        if file.starts_with(&allowed) {
140            continue;
141        }
142
143        let contents = read(&file)?;
144        if contents.contains("@tauri-apps/api") {
145            failures.push(format!(
146                "{} imports `@tauri-apps/api` — go through `@origin/client` instead \
147                 (ADR-0010)",
148                relative(root, &file)
149            ));
150        }
151    }
152
153    Ok(failures)
154}
155
156/// Rule 14: least privilege in every capability file, wherever it lives.
157fn check_capabilities(root: &Path) -> Result<Vec<String>, String> {
158    let mut failures = Vec::new();
159
160    for file in files_with_extension(root, "json")? {
161        if !file
162            .parent()
163            .is_some_and(|parent| parent.ends_with("capabilities"))
164        {
165            continue;
166        }
167
168        let contents = read(&file)?;
169        for permission in FORBIDDEN_PERMISSIONS {
170            if contents.contains(permission) {
171                failures.push(format!(
172                    "{} grants `{permission}` — that is not least privilege (ADR-0007)",
173                    relative(root, &file)
174                ));
175            }
176        }
177    }
178
179    Ok(failures)
180}
181
182/// `app.toml` and `tauri.conf.json` must agree on who this product is.
183///
184/// They are edited in different situations — a version bump here, a window tweak there
185/// — and a mismatch is invisible until a release ships under the wrong version or an
186/// installer collides with another application's identifier.
187fn check_manifest_matches_tauri_config(root: &Path) -> Result<Vec<String>, String> {
188    let mut failures = Vec::new();
189
190    for manifest_path in crate::find_manifests(root)? {
191        let project = manifest_path.parent().unwrap_or(root);
192        let config_path = project.join("src-tauri").join("tauri.conf.json");
193
194        let config = match read(&config_path) {
195            Ok(config) => config,
196            Err(error) => {
197                failures.push(error);
198                continue;
199            }
200        };
201        let config: serde_json::Value = match serde_json::from_str(&config) {
202            Ok(config) => config,
203            Err(error) => {
204                failures.push(format!("{}: {error}", relative(root, &config_path)));
205                continue;
206            }
207        };
208
209        let manifest = match origin_manifest::Manifest::load(&manifest_path) {
210            Ok(manifest) => manifest,
211            Err(error) => {
212                failures.push(error.to_string());
213                continue;
214            }
215        };
216
217        for (field, expected, actual) in [
218            ("identifier", &manifest.product.id, config.get("identifier")),
219            (
220                "productName",
221                &manifest.product.name,
222                config.get("productName"),
223            ),
224            ("version", &manifest.product.version, config.get("version")),
225        ] {
226            let actual = actual
227                .and_then(serde_json::Value::as_str)
228                .unwrap_or_default();
229            if actual != expected {
230                failures.push(format!(
231                    "{}: `{field}` is `{actual}`, but app.toml says `{expected}`",
232                    relative(root, &config_path)
233                ));
234            }
235        }
236    }
237
238    Ok(failures)
239}
240
241/// Rule 15, second half: every command the frontend calls must exist in Rust.
242///
243/// The name is a string on one side and a function on the other, so nothing else
244/// catches a typo or a renamed command until it fails at runtime in front of a user.
245fn check_commands_exist(root: &Path) -> Result<Vec<String>, String> {
246    let mut defined = Vec::new();
247    for file in rust_sources(root)? {
248        let contents = read(&file)?;
249        defined.extend(tauri_command_names(&contents));
250    }
251
252    let mut failures = Vec::new();
253    for file in frontend_sources(root)? {
254        let contents = read(&file)?;
255        for name in invoked_command_names(&contents) {
256            if !defined.contains(&name) {
257                failures.push(format!(
258                    "{} calls the command `{name}`, which no `#[tauri::command]` defines",
259                    relative(root, &file)
260                ));
261            }
262        }
263    }
264
265    Ok(failures)
266}
267
268/// Names of functions annotated with `#[tauri::command]`.
269fn tauri_command_names(contents: &str) -> Vec<String> {
270    let mut names = Vec::new();
271    let mut annotated = false;
272
273    for line in contents.lines() {
274        let line = line.trim();
275
276        if line.starts_with("#[tauri::command") {
277            annotated = true;
278            continue;
279        }
280
281        if annotated {
282            if line.starts_with("#[") {
283                continue;
284            }
285            if let Some(name) = line
286                .split("fn ")
287                .nth(1)
288                .and_then(|rest| rest.split(['(', '<', ' ']).next())
289            {
290                names.push(name.to_owned());
291            }
292            annotated = false;
293        }
294    }
295
296    names
297}
298
299/// Command names passed to the client's `command("...")` helper.
300fn invoked_command_names(contents: &str) -> Vec<String> {
301    let mut names = Vec::new();
302    let mut remaining = contents;
303
304    while let Some(index) = remaining.find("command") {
305        remaining = &remaining[index + "command".len()..];
306        let Some(arguments) = remaining.find('(') else {
307            break;
308        };
309        let before_arguments = &remaining[..arguments];
310        let plain_call = before_arguments.trim().is_empty();
311        let generic_call = before_arguments.trim_start().starts_with('<')
312            && before_arguments.trim_end().ends_with('>');
313        if !(plain_call || generic_call) {
314            continue;
315        }
316
317        let rest = &remaining[arguments + 1..];
318        let rest = rest.trim_start();
319        let Some(quote) = rest
320            .chars()
321            .next()
322            .filter(|quote| matches!(quote, '"' | '\''))
323        else {
324            continue;
325        };
326        let value = &rest[quote.len_utf8()..];
327        let Some(close) = value.find(quote) else {
328            continue;
329        };
330        names.push(value[..close].to_owned());
331    }
332
333    names
334}
335
336// ---------------------------------------------------------------------------
337// helpers
338// ---------------------------------------------------------------------------
339
340/// Whether `name` is a crate living in one of `layers`.
341fn is_workspace_member_of(root: &Path, name: &str, layers: &[&str]) -> bool {
342    layers.iter().any(|layer| {
343        let direct = root.join(layer).join(name).join("Cargo.toml").exists();
344        // `examples/demo/src-tauri` does not follow the flat layout.
345        let nested =
346            root.join(layer).join("demo").join("src-tauri").exists() && name == "origin-demo";
347        direct || (*layer == "examples" && nested)
348    })
349}
350
351fn crates_in(directory: &Path) -> Result<Vec<PathBuf>, String> {
352    if !directory.exists() {
353        return Ok(Vec::new());
354    }
355
356    let mut manifests = Vec::new();
357    for entry in read_dir(directory)? {
358        let manifest = entry.join("Cargo.toml");
359        if manifest.exists() {
360            manifests.push(manifest);
361        }
362    }
363    Ok(manifests)
364}
365
366/// Every workspace member's dependency names, keyed by package name.
367///
368/// Backed by `cargo metadata` rather than a manifest's top-level TOML keys: reading
369/// only `[dependencies]` and `[build-dependencies]` missed `[dev-dependencies]`,
370/// target-specific tables (`[target.'cfg(...)'.dependencies]`), and reported a renamed
371/// dependency's local alias (`desktop = { package = "tauri" }`) instead of the crate it
372/// actually names — three ways a layering violation could pass this check unnoticed.
373/// `cargo metadata` resolves all of that once, for every member, in one call.
374fn workspace_dependencies(root: &Path) -> Result<HashMap<String, Vec<String>>, String> {
375    let output = Command::new("cargo")
376        .args(["metadata", "--no-deps", "--format-version", "1"])
377        .current_dir(root)
378        .output()
379        .map_err(|error| format!("cannot run cargo metadata: {error}"))?;
380
381    if !output.status.success() {
382        return Err(format!(
383            "cargo metadata failed: {}",
384            String::from_utf8_lossy(&output.stderr)
385        ));
386    }
387
388    let metadata: serde_json::Value = serde_json::from_slice(&output.stdout)
389        .map_err(|error| format!("cannot parse cargo metadata output: {error}"))?;
390
391    dependencies_from_metadata(&metadata)
392}
393
394/// The parsing half of [`workspace_dependencies`], separated so it can be tested
395/// against a fixed `cargo metadata` document instead of a real cargo invocation.
396fn dependencies_from_metadata(
397    metadata: &serde_json::Value,
398) -> Result<HashMap<String, Vec<String>>, String> {
399    let packages = metadata
400        .get("packages")
401        .and_then(serde_json::Value::as_array)
402        .ok_or_else(|| "cargo metadata: no `packages` array in its output".to_owned())?;
403
404    let mut by_package = HashMap::new();
405    for package in packages {
406        let Some(name) = package.get("name").and_then(serde_json::Value::as_str) else {
407            continue;
408        };
409
410        // The dependency's own name, not `rename` — a `package = "..."` alias is a
411        // local name for the crate in *this* manifest's code, not a different crate.
412        // No filtering on `kind` (normal/dev/build) or `target`: all of them are real
413        // dependency edges this check must see.
414        let dependencies = package
415            .get("dependencies")
416            .and_then(serde_json::Value::as_array)
417            .map(|dependencies| {
418                dependencies
419                    .iter()
420                    .filter_map(|dependency| {
421                        dependency
422                            .get("name")
423                            .and_then(serde_json::Value::as_str)
424                            .map(str::to_owned)
425                    })
426                    .collect()
427            })
428            .unwrap_or_default();
429
430        by_package.insert(name.to_owned(), dependencies);
431    }
432
433    Ok(by_package)
434}
435
436fn rust_sources(directory: &Path) -> Result<Vec<PathBuf>, String> {
437    files_with_extension(directory, "rs")
438}
439
440fn frontend_sources(directory: &Path) -> Result<Vec<PathBuf>, String> {
441    let mut files = files_with_extension(directory, "ts")?;
442    files.extend(files_with_extension(directory, "svelte")?);
443    Ok(files)
444}
445
446fn files_with_extension(directory: &Path, extension: &str) -> Result<Vec<PathBuf>, String> {
447    if !directory.exists() {
448        return Ok(Vec::new());
449    }
450
451    let mut files = Vec::new();
452    for entry in read_dir(directory)? {
453        let name = entry
454            .file_name()
455            .unwrap_or_default()
456            .to_string_lossy()
457            .into_owned();
458        if entry.is_dir() {
459            // Never walk into build output or installed packages.
460            if matches!(
461                name.as_str(),
462                "node_modules" | "target" | "dist" | "gen" | ".git"
463            ) {
464                continue;
465            }
466            files.extend(files_with_extension(&entry, extension)?);
467        } else if entry.extension().is_some_and(|found| found == extension) {
468            files.push(entry);
469        }
470    }
471    Ok(files)
472}
473
474fn read_dir(directory: &Path) -> Result<Vec<PathBuf>, String> {
475    let mut entries: Vec<PathBuf> = fs::read_dir(directory)
476        .map_err(|error| format!("cannot read {}: {error}", directory.display()))?
477        .filter_map(Result::ok)
478        .map(|entry| entry.path())
479        .collect();
480    entries.sort();
481    Ok(entries)
482}
483
484fn read(file: &Path) -> Result<String, String> {
485    fs::read_to_string(file).map_err(|error| format!("cannot read {}: {error}", file.display()))
486}
487
488fn relative(root: &Path, file: &Path) -> String {
489    file.strip_prefix(root)
490        .unwrap_or(file)
491        .to_string_lossy()
492        .into_owned()
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    #[test]
500    fn command_calls_with_and_without_a_generic_are_found() {
501        let source = r#"
502            command<Result>("with_result");
503            command('without_result');
504        "#;
505
506        assert_eq!(
507            invoked_command_names(source),
508            vec!["with_result", "without_result"]
509        );
510    }
511
512    /// The three gaps a top-level-TOML-keys check had: a dev-dependency, a
513    /// target-specific dependency, and a renamed dependency reported under its real
514    /// name rather than its local alias.
515    #[test]
516    fn dev_target_and_renamed_dependencies_are_all_reported_by_their_real_name() {
517        let metadata = serde_json::json!({
518            "packages": [
519                {
520                    "name": "origin-example",
521                    "dependencies": [
522                        { "name": "origin-core", "rename": null, "kind": null, "target": null },
523                        { "name": "tauri", "rename": "desktop", "kind": null, "target": null },
524                        { "name": "origin-storage", "rename": null, "kind": "dev", "target": null },
525                        {
526                            "name": "winapi",
527                            "rename": null,
528                            "kind": null,
529                            "target": "cfg(windows)"
530                        }
531                    ]
532                }
533            ]
534        });
535
536        let dependencies = dependencies_from_metadata(&metadata).unwrap();
537
538        let mut names = dependencies["origin-example"].clone();
539        names.sort();
540        assert_eq!(
541            names,
542            vec!["origin-core", "origin-storage", "tauri", "winapi"]
543        );
544    }
545
546    #[test]
547    fn a_package_with_no_dependencies_field_is_reported_as_having_none() {
548        let metadata = serde_json::json!({
549            "packages": [{ "name": "origin-leaf" }]
550        });
551
552        let dependencies = dependencies_from_metadata(&metadata).unwrap();
553        assert_eq!(dependencies["origin-leaf"], Vec::<String>::new());
554    }
555}