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()] + ), + ] + ); +}