From 8573a689013d844ddce8ca87524d990fc378fe16 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 10:01:11 +0200 Subject: [PATCH] =?UTF-8?q?W=C3=A4chter=20f=C3=BCr=20den=20Kern=20sch?= =?UTF-8?q?=C3=A4rfen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clippy sperrt im Kern Hash-Tabellen, Uhr, Dateien, Threads und Umgebung über aufgelöste Pfade, auch bei Aliasen und gruppierten Importen. Der Textwächter kennt globalen veränderlichen Zustand und Prozesse, prüft sich selbst, und ein neuer Test hält die Abhängigkeiten des Kerns leer. Co-Authored-By: Claude Haiku 4.5 --- core/clippy.toml | 24 +++++++++++++ core/tests/forbidden_apis.rs | 64 +++++++++++++++++++++++++++------ core/tests/no_dependencies.rs | 67 +++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 core/clippy.toml create mode 100644 core/tests/no_dependencies.rs diff --git a/core/clippy.toml b/core/clippy.toml new file mode 100644 index 0000000..b9246ee --- /dev/null +++ b/core/clippy.toml @@ -0,0 +1,24 @@ +# Paths the deterministic core must never use. Clippy resolves imports, so +# aliases and grouped imports such as `use std::{fs, env}` are caught too. +# The text search in core/tests/forbidden_apis.rs covers the rest. + +disallowed-types = [ + { path = "std::collections::HashMap", reason = "iteration order differs between runs; use BTreeMap or Vec" }, + { path = "std::collections::HashSet", reason = "iteration order differs between runs; use BTreeSet or Vec" }, + { path = "std::hash::RandomState", reason = "seeded differently in every run" }, + { path = "std::hash::DefaultHasher", reason = "seeded differently in every run" }, + { path = "std::time::Instant", reason = "the core must not read the clock" }, + { path = "std::time::SystemTime", reason = "the core must not read the clock" }, +] + +disallowed-methods = [ + { path = "std::env::var", reason = "the core must not depend on the environment" }, + { path = "std::env::args", reason = "the core must not depend on the environment" }, + { path = "std::thread::spawn", reason = "the core must stay single-threaded" }, + { path = "std::fs::read", reason = "the core must not touch files" }, + { path = "std::fs::read_to_string", reason = "the core must not touch files" }, + { path = "std::fs::write", reason = "the core must not touch files" }, + { path = "std::fs::File::open", reason = "the core must not touch files" }, + { path = "std::fs::File::create", reason = "the core must not touch files" }, + { path = "std::process::exit", reason = "the core must not end the process" }, +] diff --git a/core/tests/forbidden_apis.rs b/core/tests/forbidden_apis.rs index 8913c42..3f56d59 100644 --- a/core/tests/forbidden_apis.rs +++ b/core/tests/forbidden_apis.rs @@ -1,5 +1,13 @@ //! Guards the lockstep rules: the core must not use anything whose result //! can differ between machines or between runs. +//! +//! Clippy checks the same rules on resolved paths (see `core/clippy.toml`), +//! which also catches aliases and grouped imports. This text search adds +//! what Clippy cannot express, such as floating point types and global +//! mutable state, and it runs even when Clippy does not. + +// Tests may read files. The rules of core/clippy.toml apply to core/src. +#![allow(clippy::disallowed_methods)] use std::fs; use std::path::{Path, PathBuf}; @@ -29,9 +37,33 @@ const FORBIDDEN: &[(&str, &str)] = &[ "std::net", "the core must not open connections; the shell does that", ), + ( + "std::io", + "the core must not read or write streams; the shell does that", + ), + ("std::process", "the core must not start or end processes"), + ("static mut", "global mutable state can differ between runs"), + ( + "thread_local", + "global mutable state can differ between runs", + ), + ("Atomic", "global mutable state can differ between runs"), ("unsafe", "the core forbids unsafe code"), ]; +/// Every forbidden term in `text`, as (line number, term, reason). +fn violations(text: &str) -> Vec<(usize, &'static str, &'static str)> { + let mut found = Vec::new(); + for (index, line) in text.lines().enumerate() { + for &(needle, reason) in FORBIDDEN { + if line.contains(needle) { + found.push((index + 1, needle, reason)); + } + } + } + found +} + fn rust_files(dir: &Path, out: &mut Vec) { for entry in fs::read_dir(dir).expect("source directory is readable") { let path = entry.expect("directory entry is readable").path(); @@ -58,16 +90,8 @@ fn core_source_uses_no_forbidden_apis() { let mut problems = Vec::new(); for file in &files { let text = fs::read_to_string(file).expect("source file is readable"); - for (number, line) in text.lines().enumerate() { - for (needle, reason) in FORBIDDEN { - if line.contains(needle) { - problems.push(format!( - "{}:{}: `{needle}`: {reason}", - file.display(), - number + 1 - )); - } - } + for (line, needle, reason) in violations(&text) { + problems.push(format!("{}:{line}: `{needle}`: {reason}", file.display())); } } assert!( @@ -76,3 +100,23 @@ fn core_source_uses_no_forbidden_apis() { problems.join("\n") ); } + +#[test] +fn every_forbidden_term_is_detected() { + for &(needle, _) in FORBIDDEN { + let text = format!("pub fn fine() {{}}\nlet x = {needle};\n"); + let found = violations(&text); + assert!( + found + .iter() + .any(|&(line, term, _)| line == 2 && term == needle), + "`{needle}` was not detected: {found:?}" + ); + } +} + +#[test] +fn clean_code_has_no_violations() { + let text = "pub fn add(a: u32, b: u32) -> u32 {\n a.wrapping_add(b)\n}\n"; + assert!(violations(text).is_empty()); +} diff --git a/core/tests/no_dependencies.rs b/core/tests/no_dependencies.rs new file mode 100644 index 0000000..3d45a7b --- /dev/null +++ b/core/tests/no_dependencies.rs @@ -0,0 +1,67 @@ +//! The core builds from the standard library alone. A foreign crate could +//! bring floating point, hash maps or threads in through the back door. + +// Tests may read files. The rules of core/clippy.toml apply to core/src. +#![allow(clippy::disallowed_methods)] + +use std::fs; +use std::path::Path; + +/// Every manifest section that may add a dependency, with the lines it +/// declares. Development dependencies are allowed, because they never reach +/// the game. +fn dependency_sections(manifest: &str) -> Vec<(String, Vec)> { + let mut sections: Vec<(String, Vec)> = Vec::new(); + let mut inside = false; + for line in manifest.lines() { + let line = line.trim(); + if line.starts_with('[') { + inside = line.contains("dependencies") && !line.contains("dev-dependencies"); + if inside { + sections.push((line.to_owned(), Vec::new())); + } + continue; + } + if inside && !line.is_empty() && !line.starts_with('#') { + if let Some((_, entries)) = sections.last_mut() { + entries.push(line.to_owned()); + } + } + } + sections +} + +#[test] +fn the_core_has_no_dependencies() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); + let manifest = fs::read_to_string(&path).expect("the manifest is readable"); + for (header, entries) in dependency_sections(&manifest) { + assert!( + !header.contains("dependencies."), + "the core declares a dependency table: {header}" + ); + assert!( + entries.is_empty(), + "{header} of the core must stay empty, found: {entries:?}" + ); + } +} + +#[test] +fn dependency_sections_are_found_with_their_entries() { + let manifest = "[package]\nname = \"x\"\n\n[dependencies]\n# a comment\nserde = \"1\"\n\n\ + [dev-dependencies]\nproptest = \"1\"\n\n[dependencies.rand]\nversion = \"0.8\"\n"; + assert_eq!( + dependency_sections(manifest), + vec![ + ( + "[dependencies]".to_owned(), + vec!["serde = \"1\"".to_owned()] + ), + ( + "[dependencies.rand]".to_owned(), + vec!["version = \"0.8\"".to_owned()] + ), + ] + ); +}