pmacs/tests/m7_9_acceptance.rs

318 lines
11 KiB
Rust

// m7_9_acceptance.rs --- T M7.9 acceptance tests.
//
//! Acceptance suite for T M7.9 ("Audit lint configuration"). The
//! five tests below correspond, in order, to the five bullets in
//! `spec/pmacs-tasks.tex` § "T M7.9 -- Audit lint configuration".
//!
//! Bullet 1: detects all seven rule classes
//! Bullet 2: emits JSON with file/line/rule/snippet
//! Bullet 3: sample CI workflow YAML present for three forges
//! Bullet 4: lint over the (pre-M7.11) REPL package's in-tree source
//! Bullet 5: 1000-line package lints in under one second
use std::collections::HashSet;
use std::path::Path;
use std::time::Instant;
use pmacs::audit::{AuditEngine, AuditReport, Severity};
// ---------------------------------------------------------------------------
// Bullet 1: seven rule classes detected on synthetic snippets
// ---------------------------------------------------------------------------
//
// The spec lists seven *rule classes*; the implementation expands them into
// fifteen tree-sitter patterns (e.g., the "no-FFI escape hatches" class
// covers four distinct patterns: ffi.cdef/load/metatype, package.loadlib,
// package.cpath mutation; consolidating them under a single rule name would
// make findings less actionable). The acceptance check confirms every
// *class* fires at least once; the per-pattern test suite in
// `src/audit/mod.rs::tests` verifies the discrimination within a class.
#[test]
fn seven_rule_classes_each_fire_on_synthetic_snippets() {
let engine = AuditEngine::new().expect("rule set compiles");
let src = r#"
-- Class 1: no private-surface access
local a = require("pmacs._internal.foo")
local b = require("pmacs.core.bar")
local _pmacs_internal_x = 1
local _core_y = 2
-- Class 2: no FFI escape hatches
ffi.cdef[[ int x; ]]
ffi.load("c")
ffi.metatype(t, mt)
package.loadlib("/lib/x.so", "init")
package.cpath = "/extra/?.so"
-- Class 3: no debug-library cancellation interference
debug.sethook(myhook, "c")
debug.setmetatable(other, mt)
-- Class 4: no environment escape
rawget(_G, "secret")
rawset(_G, "ok", 1)
setfenv(2, env)
getfenv(0)
-- Class 5: reach-around to other packages
local x = require("otherpkg.private")
-- Class 6: filesystem mutation (warn)
io.open("/tmp/x", "w")
os.remove("/tmp/y")
os.rename("a", "b")
-- Class 7: process spawning (warn)
io.popen("ls")
os.execute("rm -rf /")
pmacs.process.spawn("ls")
"#;
let findings = engine.audit_source("synthetic.lua", src);
let rules: HashSet<&str> = findings.iter().map(|f| f.rule.as_str()).collect();
let class_to_rule_alternatives: &[(&str, &[&str])] = &[
(
"private-surface",
&[
"no-private-surface-require",
"no-private-surface-identifier",
],
),
(
"ffi-and-loadlib",
&[
"no-ffi-call",
"no-package-loadlib",
"no-package-cpath-mutation",
],
),
(
"debug-cancellation",
&["no-debug-sethook", "no-debug-setmetatable"],
),
(
"env-escape",
&["no-rawget-rawset-on-globals", "no-setfenv-getfenv"],
),
("reach-around", &["reach-around-require"]),
(
"fs-mutation",
&["no-fs-mutation-io-open-write", "no-fs-mutation-os"],
),
(
"process-spawn",
&[
"no-process-spawn-io",
"no-process-spawn-os",
"no-process-spawn-pmacs",
],
),
];
for (class, alts) in class_to_rule_alternatives {
let any = alts.iter().any(|r| rules.contains(*r));
assert!(
any,
"rule class `{class}` did not fire; alternatives {alts:?} not in {rules:?}"
);
}
// And each pattern fires at least once (distinct rules >= 13/15;
// private-surface-{require,identifier}, both ffi-call patterns,
// both fs-mutation patterns, both env-escape patterns, all three
// spawn patterns, etc., are all exercised above). Two patterns
// that cover sub-cases of a class are tested in the unit suite.
assert!(
rules.len() >= 13,
"expected ~13+ distinct rule names, got {}: {rules:?}",
rules.len()
);
}
// ---------------------------------------------------------------------------
// Bullet 2: JSON report shape
// ---------------------------------------------------------------------------
#[test]
fn json_report_has_file_line_rule_severity_message_snippet() {
let engine = AuditEngine::new().unwrap();
let findings = engine.audit_source("demo.lua", "ffi.load(\"c\")\nio.popen(\"ls\")\n");
let report = AuditReport::new(findings);
let json = serde_json::to_value(&report).expect("serialize");
let arr = json["findings"].as_array().expect("findings is an array");
assert!(!arr.is_empty(), "report has at least one finding");
let first = &arr[0];
for key in [
"file", "line", "column", "rule", "severity", "message", "snippet",
] {
assert!(
first.get(key).is_some(),
"finding missing field `{key}` in {first:?}"
);
}
assert!(json["summary"]["errors"].is_number());
assert!(json["summary"]["warnings"].is_number());
assert!(json["summary"]["infos"].is_number());
// Severity values are stable lowercase.
let sev = first["severity"].as_str().expect("severity is a string");
assert!(
matches!(sev, "error" | "warning" | "info"),
"unknown severity `{sev}`"
);
}
#[test]
fn json_round_trips_through_serde() {
let engine = AuditEngine::new().unwrap();
let findings = engine.audit_source("demo.lua", "ffi.load(\"c\")\n");
let report = AuditReport::new(findings);
let s = serde_json::to_string(&report).unwrap();
let back: AuditReport = serde_json::from_str(&s).unwrap();
assert_eq!(back.summary.errors, report.summary.errors);
assert_eq!(back.findings.len(), report.findings.len());
assert_eq!(back.findings[0].rule, report.findings[0].rule);
}
// ---------------------------------------------------------------------------
// Bullet 3: sample CI workflows present for three forges
// ---------------------------------------------------------------------------
#[test]
fn sample_ci_workflows_exist_for_three_forges() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let forges = [
"audit/ci/github-actions.yml",
"audit/ci/gitlab-ci.yml",
"audit/ci/forgejo-actions.yml",
];
for f in forges {
let p = root.join(f);
let body = std::fs::read_to_string(&p)
.unwrap_or_else(|e| panic!("expected sample CI workflow at {p:?}: {e}"));
assert!(
body.contains("pmacs-audit") || body.contains("pmacs_audit"),
"{f} should reference the pmacs-audit binary; got:\n{body}"
);
// Sanity-check the file looks like YAML (not a Markdown stub).
assert!(
body.contains(':'),
"{f} should look like YAML (contain key:value pairs)"
);
}
}
// ---------------------------------------------------------------------------
// Bullet 4: lint runs against the in-tree REPL source
// ---------------------------------------------------------------------------
//
// As of T M7.11 the REPL is a manifest-bearing package at
// `builtin/packages/repl/`. The acceptance contract: any findings
// against the package's source must be either zero or
// *classifiable* -- that is, instances of rules whose Warning/Info
// severity reflects expected REPL behavior (process spawning,
// primarily). A finding under any Error-severity rule is a hard
// failure here.
//
// `tests/m7_11_acceptance.rs` repeats this assertion as part of the
// M7.11 migration's own acceptance bullet 4; the duplication is
// intentional, not a copy-paste oversight: each milestone owns its
// own audit gate, and the M7.9 suite also keeps a check so a
// future migration that renames or moves the REPL doesn't silently
// drop audit coverage.
#[test]
fn lint_against_in_tree_repl_source_has_no_error_severity_findings() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let repl_dir = root.join("builtin/packages/repl");
assert!(
repl_dir.is_dir(),
"expected migrated REPL package dir at {repl_dir:?} (T M7.11)"
);
let engine = AuditEngine::new().unwrap();
let findings = engine
.audit_dir(&repl_dir)
.expect("read+audit migrated REPL package");
let errors: Vec<_> = findings
.iter()
.filter(|f| f.severity == Severity::Error)
.collect();
assert!(
errors.is_empty(),
"in-tree REPL has Error-severity findings; either fix the REPL or reclassify the rule.\n\
findings: {errors:#?}"
);
// The REPL is permitted to spawn processes (it *is* the
// process-spawning subsystem). Document the expected
// classification: every finding is Warn-level
// `no-process-spawn-pmacs` against `pmacs.process.spawn`.
for f in &findings {
assert_eq!(
f.severity,
Severity::Warning,
"unexpected non-warning finding in REPL: {f:?}"
);
assert_eq!(
f.rule, "no-process-spawn-pmacs",
"unexpected rule in REPL findings (expected only the spawn warn): {f:?}"
);
}
}
// ---------------------------------------------------------------------------
// Bullet 5: 1000-line perf budget
// ---------------------------------------------------------------------------
#[test]
fn lint_completes_in_under_one_second_on_thousand_line_package() {
// Build a 1000-line synthetic source. Mix of patterns so the
// worst-case (all 15 rules engaged) is exercised, not a tight
// loop of identical lines that the parser would chew through
// unrealistically fast.
let mut src = String::new();
let pattern = [
"local a = require(\"foo\")",
"ffi.cdef[[ int x; ]]",
"io.popen(\"ls\")",
"os.execute(\"echo hi\")",
"local b = require(\"otherpkg.x\")",
"io.open(\"/tmp/x\", \"w\")",
"os.remove(\"/tmp/y\")",
"debug.sethook(h, \"c\")",
"rawget(_G, \"k\")",
"package.cpath = \"/extra/?.so\"",
];
for i in 0..1000 {
src.push_str(pattern[i % pattern.len()]);
src.push('\n');
}
assert_eq!(src.lines().count(), 1000);
let engine = AuditEngine::new().unwrap();
let t = Instant::now();
let findings = engine.audit_source("perf.lua", &src);
let elapsed = t.elapsed();
assert!(
elapsed.as_secs_f64() < 1.0,
"lint of 1000-line package took {elapsed:?}; spec budget is < 1s"
);
assert!(
!findings.is_empty(),
"synthetic 1000-line file should produce findings (engine alive check)"
);
// For empirical record: print to stderr so CI logs capture it.
eprintln!(
"[T M7.9] 1000-line synthetic perf: {:?} for {} findings",
elapsed,
findings.len()
);
}