From 95016c52a88d71bb3f1426e345bc042a2f2bf1e4 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 13 May 2026 13:53:36 +0700 Subject: [PATCH 1/2] #4 feat reshape rule schema with external tagged ruledefinition --- .../schemas/rule.schema.json | 113 +++++++++++++----- crates/rustmanifest-schema/src/lib.rs | 48 +++++--- 2 files changed, 119 insertions(+), 42 deletions(-) diff --git a/crates/rustmanifest-schema/schemas/rule.schema.json b/crates/rustmanifest-schema/schemas/rule.schema.json index 1180a58..f91aa74 100644 --- a/crates/rustmanifest-schema/schemas/rule.schema.json +++ b/crates/rustmanifest-schema/schemas/rule.schema.json @@ -1,9 +1,13 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Rule", - "description": "A single review rule definition produced by the rules pack.", + "description": "A single review rule definition produced by the rules pack.\n\nThe analysis tier is implicit in the [`RuleDefinition`] variant carried by\nthe `definition` field; there is no separate `tier` enum.", "type": "object", "properties": { + "definition": { + "description": "Tier-specific definition (pattern, AST, or semantic).", + "$ref": "#/$defs/RuleDefinition" + }, "id": { "description": "Stable rule identifier, e.g. `RM-SEC-001`.", "type": "string" @@ -16,10 +20,6 @@ "description": "Default severity declared by the rules pack.", "$ref": "#/$defs/Severity" }, - "tier": { - "description": "Analysis tier this rule belongs to.", - "$ref": "#/$defs/Tier" - }, "title": { "description": "Short human-readable title.", "type": "string" @@ -28,12 +28,91 @@ "additionalProperties": false, "required": [ "id", - "tier", "severity", "title", - "rationale-uri" + "rationale-uri", + "definition" ], "$defs": { + "RuleDefinition": { + "description": "Tier-specific configuration carried inside a [`Rule`].\n\nThe enum uses **external tagging**: the variant name appears as the only\nkey of the outer object on the wire, and the variant's payload is the\nnested value. This maps naturally to TOML's `[definition.pattern]` table\nsyntax while remaining unambiguous in JSON.", + "oneOf": [ + { + "description": "Tier 1 — text-level pattern scan via regular expression.", + "type": "object", + "properties": { + "pattern": { + "type": "object", + "properties": { + "exclude-globs": { + "description": "Glob patterns of files the rule MUST NOT inspect.", + "type": "array", + "default": [], + "items": { + "type": "string" + } + }, + "regex": { + "description": "Regular expression matched against file contents.", + "type": "string" + } + }, + "required": [ + "regex" + ] + } + }, + "additionalProperties": false, + "required": [ + "pattern" + ] + }, + { + "description": "Tier 2 — `syn` AST traversal identified by a built-in check name.", + "type": "object", + "properties": { + "ast": { + "type": "object", + "properties": { + "check": { + "description": "Identifier of the built-in AST check implementation.", + "type": "string" + } + }, + "required": [ + "check" + ] + } + }, + "additionalProperties": false, + "required": [ + "ast" + ] + }, + { + "description": "Tier 3 — semantic analysis identified by a built-in check name.", + "type": "object", + "properties": { + "semantic": { + "type": "object", + "properties": { + "check": { + "description": "Identifier of the built-in semantic check implementation.", + "type": "string" + } + }, + "required": [ + "check" + ] + } + }, + "additionalProperties": false, + "required": [ + "semantic" + ] + } + ] + }, "Severity": { "description": "Severity of a rule or a finding.", "oneOf": [ @@ -58,26 +137,6 @@ "const": "hint" } ] - }, - "Tier": { - "description": "Analysis tier indicating the cost and precision class of a rule.", - "oneOf": [ - { - "description": "Pattern scan (regex / aho-corasick). Fast, high recall, medium\nprecision.", - "type": "string", - "const": "pattern" - }, - { - "description": "`syn`-based AST traversal. Structural and local semantic checks.", - "type": "string", - "const": "ast" - }, - { - "description": "Full semantic analysis (rust-analyzer / cargo integrations). Slow and\nprecise.", - "type": "string", - "const": "semantic" - } - ] } } } \ No newline at end of file diff --git a/crates/rustmanifest-schema/src/lib.rs b/crates/rustmanifest-schema/src/lib.rs index da4720a..5f03dc4 100644 --- a/crates/rustmanifest-schema/src/lib.rs +++ b/crates/rustmanifest-schema/src/lib.rs @@ -15,33 +15,51 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// A single review rule definition produced by the rules pack. +/// +/// The analysis tier is implicit in the [`RuleDefinition`] variant carried by +/// the `definition` field; there is no separate `tier` enum. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct Rule { /// Stable rule identifier, e.g. `RM-SEC-001`. pub id: String, - /// Analysis tier this rule belongs to. - pub tier: Tier, /// Default severity declared by the rules pack. pub severity: Severity, /// Short human-readable title. pub title: String, /// `rustmanifest://` URI pointing to the rationale section. - pub rationale_uri: String + pub rationale_uri: String, + /// Tier-specific definition (pattern, AST, or semantic). + pub definition: RuleDefinition } -/// Analysis tier indicating the cost and precision class of a rule. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "kebab-case")] -pub enum Tier { - /// Pattern scan (regex / aho-corasick). Fast, high recall, medium - /// precision. - Pattern, - /// `syn`-based AST traversal. Structural and local semantic checks. - Ast, - /// Full semantic analysis (rust-analyzer / cargo integrations). Slow and - /// precise. - Semantic +/// Tier-specific configuration carried inside a [`Rule`]. +/// +/// The enum uses **external tagging**: the variant name appears as the only +/// key of the outer object on the wire, and the variant's payload is the +/// nested value. This maps naturally to TOML's `[definition.pattern]` table +/// syntax while remaining unambiguous in JSON. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case", rename_all_fields = "kebab-case")] +pub enum RuleDefinition { + /// Tier 1 — text-level pattern scan via regular expression. + Pattern { + /// Regular expression matched against file contents. + regex: String, + /// Glob patterns of files the rule MUST NOT inspect. + #[serde(default)] + exclude_globs: Vec + }, + /// Tier 2 — `syn` AST traversal identified by a built-in check name. + Ast { + /// Identifier of the built-in AST check implementation. + check: String + }, + /// Tier 3 — semantic analysis identified by a built-in check name. + Semantic { + /// Identifier of the built-in semantic check implementation. + check: String + } } /// Severity of a rule or a finding. From 090548431b5e48547d25ade745c380c735aa9a6a Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 13 May 2026 13:53:40 +0700 Subject: [PATCH 2/2] #4 feat add rules pack with build script and first five tier 1 rules --- Cargo.lock | 117 +++++++++++++++++ Cargo.toml | 2 + crates/rustmanifest-rules-core/Cargo.toml | 15 +++ crates/rustmanifest-rules-core/build.rs | 90 +++++++++++++ .../rules/RM-PERF-001/fail.rs | 10 ++ .../rules/RM-PERF-001/pass.rs | 10 ++ .../rules/RM-PERF-001/rule.toml | 11 ++ .../rules/RM-RUST-001/fail.rs | 10 ++ .../rules/RM-RUST-001/pass.rs | 13 ++ .../rules/RM-RUST-001/rule.toml | 11 ++ .../rules/RM-SEC-001/fail.rs | 7 + .../rules/RM-SEC-001/pass.rs | 10 ++ .../rules/RM-SEC-001/rule.toml | 11 ++ .../rules/RM-SEC-002/fail.rs | 6 + .../rules/RM-SEC-002/pass.rs | 13 ++ .../rules/RM-SEC-002/rule.toml | 11 ++ .../rules/RM-SEC-003/fail.rs | 8 ++ .../rules/RM-SEC-003/pass.rs | 8 ++ .../rules/RM-SEC-003/rule.toml | 11 ++ crates/rustmanifest-rules-core/src/lib.rs | 32 ++++- crates/rustmanifest-rules-core/tests/rules.rs | 120 ++++++++++++++++++ 21 files changed, 523 insertions(+), 3 deletions(-) create mode 100644 crates/rustmanifest-rules-core/build.rs create mode 100644 crates/rustmanifest-rules-core/rules/RM-PERF-001/fail.rs create mode 100644 crates/rustmanifest-rules-core/rules/RM-PERF-001/pass.rs create mode 100644 crates/rustmanifest-rules-core/rules/RM-PERF-001/rule.toml create mode 100644 crates/rustmanifest-rules-core/rules/RM-RUST-001/fail.rs create mode 100644 crates/rustmanifest-rules-core/rules/RM-RUST-001/pass.rs create mode 100644 crates/rustmanifest-rules-core/rules/RM-RUST-001/rule.toml create mode 100644 crates/rustmanifest-rules-core/rules/RM-SEC-001/fail.rs create mode 100644 crates/rustmanifest-rules-core/rules/RM-SEC-001/pass.rs create mode 100644 crates/rustmanifest-rules-core/rules/RM-SEC-001/rule.toml create mode 100644 crates/rustmanifest-rules-core/rules/RM-SEC-002/fail.rs create mode 100644 crates/rustmanifest-rules-core/rules/RM-SEC-002/pass.rs create mode 100644 crates/rustmanifest-rules-core/rules/RM-SEC-002/rule.toml create mode 100644 crates/rustmanifest-rules-core/rules/RM-SEC-003/fail.rs create mode 100644 crates/rustmanifest-rules-core/rules/RM-SEC-003/pass.rs create mode 100644 crates/rustmanifest-rules-core/rules/RM-SEC-003/rule.toml create mode 100644 crates/rustmanifest-rules-core/tests/rules.rs diff --git a/Cargo.lock b/Cargo.lock index b2d2b92..fcc6fc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "1.0.0" @@ -104,12 +113,34 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -172,6 +203,35 @@ dependencies = [ "syn", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rustmanifest-cli" version = "0.0.0" @@ -228,7 +288,10 @@ dependencies = [ name = "rustmanifest-rules-core" version = "0.0.0" dependencies = [ + "regex", "rustmanifest-schema", + "serde_json", + "toml", ] [[package]] @@ -320,6 +383,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "strsim" version = "0.11.1" @@ -337,6 +409,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -364,6 +475,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 04818d8..2890002 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" schemars = "1" clap = { version = "4", features = ["derive"] } +regex = "1" +toml = "1" [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/rustmanifest-rules-core/Cargo.toml b/crates/rustmanifest-rules-core/Cargo.toml index ead5fea..4e666f5 100644 --- a/crates/rustmanifest-rules-core/Cargo.toml +++ b/crates/rustmanifest-rules-core/Cargo.toml @@ -14,9 +14,24 @@ authors.workspace = true readme = "README.md" categories = ["development-tools"] keywords = ["rust", "lint", "code-review", "rules"] +build = "build.rs" +include = [ + "src/**/*", + "rules/**/*", + "build.rs", + "Cargo.toml", + "README.md", +] [dependencies] +regex = { workspace = true } rustmanifest-schema = { workspace = true } +serde_json = { workspace = true } + +[build-dependencies] +rustmanifest-schema = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } [lints] workspace = true diff --git a/crates/rustmanifest-rules-core/build.rs b/crates/rustmanifest-rules-core/build.rs new file mode 100644 index 0000000..efa0c30 --- /dev/null +++ b/crates/rustmanifest-rules-core/build.rs @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Build script that bundles every rule defined under `rules/` into a single +//! `rules.json` artifact emitted in `OUT_DIR`. The runtime crate +//! `include_str!`s the result and parses it once on first access through a +//! `LazyLock`. + +use std::{ + env, fs, + io::{self, Write}, + path::{Path, PathBuf} +}; + +use rustmanifest_schema::Rule; + +type BuildError = Box; + +fn main() -> Result<(), BuildError> { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); + let rules_dir = manifest_dir.join("rules"); + let out_dir = PathBuf::from(env::var("OUT_DIR")?); + + emit_rerun(&rules_dir)?; + + let rules = collect_rules(&rules_dir)?; + let json = serde_json::to_string_pretty(&rules)?; + fs::write(out_dir.join("rules.json"), json)?; + + Ok(()) +} + +fn collect_rules(rules_dir: &Path) -> Result, BuildError> { + let mut entries: Vec<_> = fs::read_dir(rules_dir)? + .filter_map(Result::ok) + .filter(|entry| entry.path().is_dir()) + .collect(); + entries.sort_by_key(std::fs::DirEntry::path); + + let mut rules = Vec::with_capacity(entries.len()); + for entry in entries { + rules.push(parse_one(&entry.path())?); + } + Ok(rules) +} + +fn parse_one(dir: &Path) -> Result { + let dir_name_os = dir + .file_name() + .ok_or_else(|| format!("cannot read file name of {}", dir.display()))?; + let dir_name = dir_name_os + .to_str() + .ok_or_else(|| format!("non-utf8 directory name at {}", dir.display()))?; + + let rule_toml = dir.join("rule.toml"); + let pass_rs = dir.join("pass.rs"); + let fail_rs = dir.join("fail.rs"); + + if !rule_toml.is_file() { + return Err(format!("missing rule.toml in {}", dir.display()).into()); + } + if !pass_rs.is_file() { + return Err(format!("missing pass.rs in {}", dir.display()).into()); + } + if !fail_rs.is_file() { + return Err(format!("missing fail.rs in {}", dir.display()).into()); + } + + let text = fs::read_to_string(&rule_toml)?; + let rule: Rule = toml::from_str(&text)?; + + if rule.id != dir_name { + return Err(format!( + "rule id {:?} does not match directory name {:?} in {}", + rule.id, + dir_name, + dir.display() + ) + .into()); + } + + Ok(rule) +} + +fn emit_rerun(rules_dir: &Path) -> io::Result<()> { + let mut stdout = io::stdout().lock(); + writeln!(stdout, "cargo::rerun-if-changed={}", rules_dir.display())?; + writeln!(stdout, "cargo::rerun-if-changed=build.rs")?; + Ok(()) +} diff --git a/crates/rustmanifest-rules-core/rules/RM-PERF-001/fail.rs b/crates/rustmanifest-rules-core/rules/RM-PERF-001/fail.rs new file mode 100644 index 0000000..066d19c --- /dev/null +++ b/crates/rustmanifest-rules-core/rules/RM-PERF-001/fail.rs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2026 RAprogramm +// SPDX-License-Identifier: MIT + +fn build_ids(count: usize) -> Vec { + let mut ids: Vec = Vec::new(); + for i in 0..count { + ids.push(i as u64); + } + ids +} diff --git a/crates/rustmanifest-rules-core/rules/RM-PERF-001/pass.rs b/crates/rustmanifest-rules-core/rules/RM-PERF-001/pass.rs new file mode 100644 index 0000000..6fa390c --- /dev/null +++ b/crates/rustmanifest-rules-core/rules/RM-PERF-001/pass.rs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2026 RAprogramm +// SPDX-License-Identifier: MIT + +fn build_ids(count: usize) -> Vec { + let mut ids: Vec = Vec::with_capacity(count); + for i in 0..count { + ids.push(i as u64); + } + ids +} diff --git a/crates/rustmanifest-rules-core/rules/RM-PERF-001/rule.toml b/crates/rustmanifest-rules-core/rules/RM-PERF-001/rule.toml new file mode 100644 index 0000000..ad1f881 --- /dev/null +++ b/crates/rustmanifest-rules-core/rules/RM-PERF-001/rule.toml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 RAprogramm +# SPDX-License-Identifier: MIT + +id = "RM-PERF-001" +severity = "warning" +title = "Vec::new() without with_capacity" +rationale-uri = "rustmanifest://methodology/performance-issues#vec-without-capacity" + +[definition.pattern] +regex = "Vec::new\\(\\)" +exclude-globs = ["**/tests/**", "**/*_test.rs", "**/test_*.rs"] diff --git a/crates/rustmanifest-rules-core/rules/RM-RUST-001/fail.rs b/crates/rustmanifest-rules-core/rules/RM-RUST-001/fail.rs new file mode 100644 index 0000000..595b0be --- /dev/null +++ b/crates/rustmanifest-rules-core/rules/RM-RUST-001/fail.rs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2026 RAprogramm +// SPDX-License-Identifier: MIT + +fn parse_port(raw: &str) -> u16 { + raw.parse().unwrap() +} + +fn must_have(name: &str) -> String { + std::env::var(name).expect("env var missing") +} diff --git a/crates/rustmanifest-rules-core/rules/RM-RUST-001/pass.rs b/crates/rustmanifest-rules-core/rules/RM-RUST-001/pass.rs new file mode 100644 index 0000000..f562bd7 --- /dev/null +++ b/crates/rustmanifest-rules-core/rules/RM-RUST-001/pass.rs @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 RAprogramm +// SPDX-License-Identifier: MIT + +#[derive(Debug)] +struct ConfigError; + +fn parse_port(raw: &str) -> Result { + raw.parse().map_err(|_| ConfigError) +} + +fn must_have(name: &str) -> Result { + std::env::var(name).map_err(|_| ConfigError) +} diff --git a/crates/rustmanifest-rules-core/rules/RM-RUST-001/rule.toml b/crates/rustmanifest-rules-core/rules/RM-RUST-001/rule.toml new file mode 100644 index 0000000..98f0fbc --- /dev/null +++ b/crates/rustmanifest-rules-core/rules/RM-RUST-001/rule.toml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 RAprogramm +# SPDX-License-Identifier: MIT + +id = "RM-RUST-001" +severity = "error" +title = "Panic in production code" +rationale-uri = "rustmanifest://methodology/rust-specific#panic-in-production" + +[definition.pattern] +regex = "\\.unwrap\\(\\)|\\.expect\\(|panic!" +exclude-globs = ["**/tests/**", "**/*_test.rs", "**/test_*.rs", "**/benches/**"] diff --git a/crates/rustmanifest-rules-core/rules/RM-SEC-001/fail.rs b/crates/rustmanifest-rules-core/rules/RM-SEC-001/fail.rs new file mode 100644 index 0000000..3f3af1a --- /dev/null +++ b/crates/rustmanifest-rules-core/rules/RM-SEC-001/fail.rs @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2026 RAprogramm +// SPDX-License-Identifier: MIT + +fn main() { + let password = "hunter2"; + let api_key = "sk-abc123"; +} diff --git a/crates/rustmanifest-rules-core/rules/RM-SEC-001/pass.rs b/crates/rustmanifest-rules-core/rules/RM-SEC-001/pass.rs new file mode 100644 index 0000000..6922058 --- /dev/null +++ b/crates/rustmanifest-rules-core/rules/RM-SEC-001/pass.rs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2026 RAprogramm +// SPDX-License-Identifier: MIT + +fn validate_password(input: &str) -> bool { + !input.is_empty() +} + +fn load_api_key_from_env() -> Option { + std::env::var("API_KEY").ok() +} diff --git a/crates/rustmanifest-rules-core/rules/RM-SEC-001/rule.toml b/crates/rustmanifest-rules-core/rules/RM-SEC-001/rule.toml new file mode 100644 index 0000000..4ba037e --- /dev/null +++ b/crates/rustmanifest-rules-core/rules/RM-SEC-001/rule.toml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 RAprogramm +# SPDX-License-Identifier: MIT + +id = "RM-SEC-001" +severity = "error" +title = "Hardcoded credentials" +rationale-uri = "rustmanifest://methodology/security-vulnerabilities#hardcoded-credentials" + +[definition.pattern] +regex = "(?i)(password|secret|api_key|token|private_key)\\s*=\\s*[\"']" +exclude-globs = ["**/tests/**", "**/*_test.rs", "**/test_*.rs"] diff --git a/crates/rustmanifest-rules-core/rules/RM-SEC-002/fail.rs b/crates/rustmanifest-rules-core/rules/RM-SEC-002/fail.rs new file mode 100644 index 0000000..86b27f7 --- /dev/null +++ b/crates/rustmanifest-rules-core/rules/RM-SEC-002/fail.rs @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2026 RAprogramm +// SPDX-License-Identifier: MIT + +fn lookup(id: i64) -> String { + format!("SELECT * FROM users WHERE id = {id}") +} diff --git a/crates/rustmanifest-rules-core/rules/RM-SEC-002/pass.rs b/crates/rustmanifest-rules-core/rules/RM-SEC-002/pass.rs new file mode 100644 index 0000000..59deab8 --- /dev/null +++ b/crates/rustmanifest-rules-core/rules/RM-SEC-002/pass.rs @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 RAprogramm +// SPDX-License-Identifier: MIT + +async fn lookup(pool: &sqlx::PgPool, id: i64) -> sqlx::Result { + sqlx::query_as!(User, "SELECT id, name FROM users WHERE id = $1", id) + .fetch_one(pool) + .await +} + +struct User { + id: i64, + name: String +} diff --git a/crates/rustmanifest-rules-core/rules/RM-SEC-002/rule.toml b/crates/rustmanifest-rules-core/rules/RM-SEC-002/rule.toml new file mode 100644 index 0000000..c9e47c4 --- /dev/null +++ b/crates/rustmanifest-rules-core/rules/RM-SEC-002/rule.toml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 RAprogramm +# SPDX-License-Identifier: MIT + +id = "RM-SEC-002" +severity = "error" +title = "SQL injection via format!" +rationale-uri = "rustmanifest://methodology/security-vulnerabilities#sql-injection" + +[definition.pattern] +regex = "format!.*SELECT|execute.*&format!" +exclude-globs = ["**/tests/**", "**/*_test.rs", "**/test_*.rs"] diff --git a/crates/rustmanifest-rules-core/rules/RM-SEC-003/fail.rs b/crates/rustmanifest-rules-core/rules/RM-SEC-003/fail.rs new file mode 100644 index 0000000..42ec84d --- /dev/null +++ b/crates/rustmanifest-rules-core/rules/RM-SEC-003/fail.rs @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2026 RAprogramm +// SPDX-License-Identifier: MIT + +use std::process::Command; + +fn remove(path: &str) -> std::io::Result { + Command::new("sh").arg(format!("rm {path}")).output() +} diff --git a/crates/rustmanifest-rules-core/rules/RM-SEC-003/pass.rs b/crates/rustmanifest-rules-core/rules/RM-SEC-003/pass.rs new file mode 100644 index 0000000..261ba66 --- /dev/null +++ b/crates/rustmanifest-rules-core/rules/RM-SEC-003/pass.rs @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2026 RAprogramm +// SPDX-License-Identifier: MIT + +use std::process::Command; + +fn list(path: &str) -> std::io::Result { + Command::new("ls").arg("-la").arg(path).output() +} diff --git a/crates/rustmanifest-rules-core/rules/RM-SEC-003/rule.toml b/crates/rustmanifest-rules-core/rules/RM-SEC-003/rule.toml new file mode 100644 index 0000000..5c4ca5d --- /dev/null +++ b/crates/rustmanifest-rules-core/rules/RM-SEC-003/rule.toml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 RAprogramm +# SPDX-License-Identifier: MIT + +id = "RM-SEC-003" +severity = "error" +title = "Command injection via format!" +rationale-uri = "rustmanifest://methodology/security-vulnerabilities#command-injection" + +[definition.pattern] +regex = "Command::new.*format!" +exclude-globs = ["**/tests/**", "**/*_test.rs", "**/test_*.rs"] diff --git a/crates/rustmanifest-rules-core/src/lib.rs b/crates/rustmanifest-rules-core/src/lib.rs index c247d42..bb5713b 100644 --- a/crates/rustmanifest-rules-core/src/lib.rs +++ b/crates/rustmanifest-rules-core/src/lib.rs @@ -3,12 +3,38 @@ //! Default rules pack for `rustmanifest`. //! -//! Phase 0 ships only the version constant and the pack identifier. Phase 1 -//! populates this crate with rules parsed from the English methodology -//! markdown together with pass/fail fixtures. +//! Rules are authored as TOML files under `rules//rule.toml` +//! alongside `pass.rs` and `fail.rs` fixtures. At build time the +//! `rustmanifest-rules-core` build script walks `rules/`, validates the +//! definitions, and emits a single canonical `rules.json` into `OUT_DIR`. +//! Runtime code accesses the parsed rules through the [`RULES`] lazily +//! initialized list. + +use std::sync::LazyLock; + +use rustmanifest_schema::Rule; /// Identifier of this rules pack, namespaced into rule IDs (e.g. `RM-SEC-001`). pub const PACK_ID: &str = "rm"; /// Semantic version of this rules pack, independent of the engine version. pub const PACK_VERSION: &str = env!("CARGO_PKG_VERSION"); + +const RULES_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/rules.json")); + +/// All rules bundled in this pack, parsed once on first access. +/// +/// Iteration order matches the order produced by the build script (sorted by +/// directory name, which equals the rule id), so the order is deterministic +/// across builds and processes. +pub static RULES: LazyLock> = LazyLock::new(parse_embedded_rules); + +#[allow( + clippy::expect_used, + reason = "RULES_JSON is generated and validated by build.rs; a parse failure here means \ + the compiled binary has been corrupted post-link, which is unrecoverable." +)] +fn parse_embedded_rules() -> Vec { + serde_json::from_str(RULES_JSON) + .expect("rustmanifest-rules-core: embedded rules.json is malformed") +} diff --git a/crates/rustmanifest-rules-core/tests/rules.rs b/crates/rustmanifest-rules-core/tests/rules.rs new file mode 100644 index 0000000..b64d9ab --- /dev/null +++ b/crates/rustmanifest-rules-core/tests/rules.rs @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: 2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Integration tests for the bundled rules pack. +//! +//! These tests load the embedded `RULES` and the source-tree fixtures and +//! assert that every rule's regex behaves as advertised on its `pass.rs` and +//! `fail.rs` files. They also verify pack-level invariants (unique IDs, ID +//! shape) that the build script enforces at build time — duplicating the +//! check here protects against future regressions to the build script +//! itself. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "test code is allowed to fail loudly on broken invariants" +)] + +use std::{collections::HashSet, fs, path::PathBuf}; + +use regex::Regex; +use rustmanifest_rules_core::RULES; +use rustmanifest_schema::RuleDefinition; + +fn rules_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("rules") +} + +#[test] +fn pack_is_not_empty() { + assert!(!RULES.is_empty(), "rules pack must not be empty"); +} + +#[test] +fn ids_are_unique() { + let mut seen: HashSet<&str> = HashSet::with_capacity(RULES.len()); + for rule in RULES.iter() { + assert!( + seen.insert(rule.id.as_str()), + "duplicate rule id detected in pack: {}", + rule.id + ); + } +} + +#[test] +fn ids_follow_naming_convention() { + let id_re = Regex::new(r"^RM-(SEC|PERF|RUST|QUAL|STRUCT)-\d{3}$").unwrap(); + for rule in RULES.iter() { + assert!( + id_re.is_match(&rule.id), + "rule id {:?} violates RM-(SEC|PERF|RUST|QUAL|STRUCT)-NNN", + rule.id + ); + } +} + +#[test] +fn pattern_rules_compile() { + for rule in RULES.iter() { + if let RuleDefinition::Pattern { + regex, .. + } = &rule.definition + { + let compiled = Regex::new(regex); + assert!( + compiled.is_ok(), + "rule {} regex failed to compile: {:?}", + rule.id, + compiled.err() + ); + } + } +} + +#[test] +fn pattern_fixtures_match_their_rule() { + let rules_dir = rules_dir(); + for rule in RULES.iter() { + let RuleDefinition::Pattern { + regex, .. + } = &rule.definition + else { + continue; + }; + let re = Regex::new(regex).unwrap(); + let dir = rules_dir.join(&rule.id); + + let fail_src = fs::read_to_string(dir.join("fail.rs")) + .unwrap_or_else(|err| panic!("{}: cannot read fail.rs: {err}", rule.id)); + let pass_src = fs::read_to_string(dir.join("pass.rs")) + .unwrap_or_else(|err| panic!("{}: cannot read pass.rs: {err}", rule.id)); + + assert!( + re.is_match(&fail_src), + "{}: fail.rs MUST trigger the regex but did not", + rule.id + ); + assert!( + !re.is_match(&pass_src), + "{}: pass.rs MUST NOT trigger the regex but did", + rule.id + ); + } +} + +#[test] +fn rule_id_matches_directory_name() { + let rules_dir = rules_dir(); + for rule in RULES.iter() { + let dir = rules_dir.join(&rule.id); + assert!( + dir.is_dir(), + "rule {}: expected directory {}", + rule.id, + dir.display() + ); + } +}