use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::time::Duration;
use lanekeep_core::{Examples, Gates, Namespace, RuleCard, RuleId, Severity};
use lanekeep_js::{Limits, ResolveError, RuleRoot, RunClock, Sandbox};
use lanekeep_wasm::{RuleSet, WasmEngine, WasmRuntime};
use serde::Deserialize;
use thiserror::Error;
pub type Hash = [u8; 32];
mod json;
pub use json::{ResolvedRule, RuleReference};
#[must_use]
pub fn hex(hash: &Hash) -> String {
use std::fmt::Write as _;
hash.iter()
.fold(String::with_capacity(64), |mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuleSpec {
pub index: usize,
pub id: RuleId,
pub languages: Vec<String>,
pub severity: Severity,
pub card: RuleCard,
pub query: String,
pub gates: Gates,
pub timeout: Option<Duration>,
pub has_reduce: bool,
pub component: Option<ComponentRule>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComponentRule {
pub path: PathBuf,
pub index: u32,
pub options: String,
pub bytes: ComponentBytes,
pub source_map: Option<ComponentBytes>,
counted_in_ruleset_hash: bool,
}
impl ComponentRule {
#[must_use]
pub const fn counted_in_ruleset_hash(&self) -> bool {
self.counted_in_ruleset_hash
}
#[must_use]
pub fn uncounted(
path: PathBuf,
index: u32,
options: String,
bytes: impl Into<ComponentBytes>,
) -> Self {
Self {
path,
index,
options,
bytes: bytes.into(),
source_map: None,
counted_in_ruleset_hash: false,
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct ComponentBytes(std::sync::Arc<[u8]>);
impl ComponentBytes {
#[must_use]
pub fn as_slice(&self) -> &[u8] {
&self.0
}
}
impl From<Vec<u8>> for ComponentBytes {
fn from(bytes: Vec<u8>) -> Self {
Self(bytes.into())
}
}
impl std::fmt::Debug for ComponentBytes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ComponentBytes")
.field("len", &self.0.len())
.finish()
}
}
#[expect(
clippy::struct_field_names,
reason = "`ruleset_hash` and `config_hash` are the names docs/architecture.md §8.1 \
gives these two cache-key inputs. Renaming them to satisfy the lint would \
make the code and the specification disagree about the same thing, which \
costs more than the repetition saves."
)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub include: Vec<String>,
pub exclude: Vec<String>,
pub rules: Vec<RuleSpec>,
pub limits: Limits,
pub ruleset_hash: Hash,
pub config_hash: Hash,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ConfigError {
#[error("cannot load config `{path}`: {detail}")]
Unreadable {
path: String,
detail: String,
},
#[error("config `{path}` failed to evaluate\n{detail}")]
Evaluation {
path: String,
detail: String,
},
#[error("config `{path}` is not valid: {detail}")]
Shape {
path: String,
detail: String,
},
#[error("rule {position} in `{path}` is not valid: {detail}")]
Rule {
position: usize,
path: String,
detail: String,
},
}
#[derive(Debug, Deserialize)]
struct RawConfig {
#[serde(default)]
include: Vec<String>,
#[serde(default)]
exclude: Vec<String>,
#[serde(default)]
namespaces: Vec<String>,
#[serde(default)]
severity: BTreeMap<String, String>,
#[serde(default)]
timeouts: RawTimeouts,
#[serde(default)]
rules: Vec<RawRule>,
}
#[derive(Debug, Default, Deserialize)]
struct RawTimeouts {
rule: Option<u64>,
global: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct RawRule {
id: Option<String>,
language: Option<RawLanguages>,
severity: Option<String>,
card: Option<RawCard>,
query: Option<String>,
#[serde(default)]
gates: Gates,
timeout: Option<u64>,
has_check: bool,
has_reduce: bool,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RawLanguages {
One(String),
Many(Vec<String>),
}
impl RawLanguages {
fn into_vec(self) -> Vec<String> {
match self {
Self::One(language) => vec![language],
Self::Many(languages) => languages,
}
}
}
#[derive(Debug, Deserialize)]
struct RawCard {
message: Option<String>,
remediation: Option<String>,
examples: Option<RawExamples>,
}
#[derive(Debug, Deserialize)]
struct RawExamples {
bad: Option<String>,
good: Option<String>,
}
const ENTRY: &str = "__lanekeep_entry__.js";
const EXTRACT: &str = r"
(() => {
const c = globalThis.__lanekeepConfig;
if (c === null || typeof c !== 'object') return JSON.stringify(null);
const rules = Array.isArray(c.rules) ? c.rules : [];
return JSON.stringify({
include: c.include ?? [],
namespaces: c.namespaces ?? [],
exclude: c.exclude ?? [],
severity: c.severity ?? {},
timeouts: c.timeouts ?? {},
rules: rules.map((r) => ({
id: r?.id ?? null,
language: r?.language ?? null,
severity: r?.severity ?? null,
card: r?.card ?? null,
query: r?.query ?? null,
gates: r?.gates ?? {},
timeout: r?.timeout ?? null,
has_check: typeof r?.check === 'function',
has_reduce: typeof r?.reduce === 'function',
})),
});
})()
";
fn entry_source(
root: &RuleRoot,
config_path: &Path,
display: &str,
) -> Result<(String, Option<json::Parsed>), ConfigError> {
if json::is_json(config_path) {
let parsed = json::parse(config_path, root.path(), root.builtin_components())?;
let source = json::rules_module(&parsed.rules);
return Ok((source, Some(parsed)));
}
let specifier =
relative_specifier(root.path(), config_path).ok_or_else(|| ConfigError::Unreadable {
path: display.to_owned(),
detail: "the config file must sit inside the rules root".to_owned(),
})?;
Ok((
format!("import config from '{specifier}';\nglobalThis.__lanekeepConfig = config;\n"),
None,
))
}
pub fn evaluate_into(
sandbox: &Sandbox,
root: &RuleRoot,
config_path: &Path,
) -> Result<(), ConfigError> {
let display = config_path.display().to_string();
let entry = root.path().join(ENTRY);
let (source, _) = entry_source(root, config_path, &display)?;
sandbox
.eval_module(&entry.display().to_string(), &source)
.map_err(|e| ConfigError::Evaluation {
path: display,
detail: e.to_string(),
})
}
pub fn load(sandbox: &Sandbox, root: &RuleRoot, config_path: &Path) -> Result<Config, ConfigError> {
load_with(sandbox, root, config_path, LoadOptions::default())
}
#[derive(Debug, Clone, Copy, Default)]
pub struct LoadOptions<'a> {
pub artifacts: Option<&'a Path>,
pub global_timeout: Option<Duration>,
}
pub fn load_with(
sandbox: &Sandbox,
root: &RuleRoot,
config_path: &Path,
options: LoadOptions<'_>,
) -> Result<Config, ConfigError> {
let display = config_path.display().to_string();
let entry = root.path().join(ENTRY);
let (source, parsed) = entry_source(root, config_path, &display)?;
sandbox
.eval_module(&entry.display().to_string(), &source)
.map_err(|e| ConfigError::Evaluation {
path: display.clone(),
detail: e.to_string(),
})?;
let json: String = sandbox.eval(EXTRACT).map_err(|e| ConfigError::Evaluation {
path: display.clone(),
detail: e.to_string(),
})?;
let extracted: Option<RawConfig> =
serde_json::from_str(&json).map_err(|e| ConfigError::Shape {
path: display.clone(),
detail: e.to_string(),
})?;
let extracted = extracted.ok_or_else(|| ConfigError::Shape {
path: display.clone(),
detail: "the default export is not an object — did you forget `export default`?".to_owned(),
})?;
let (raw, resolved) = match parsed {
Some(parsed) => (
RawConfig {
rules: extracted.rules,
..parsed.config
},
parsed.rules,
),
None => (extracted, Vec::new()),
};
build(sandbox, root, raw, &display, &resolved, options)
}
fn build(
sandbox: &Sandbox,
root: &RuleRoot,
raw: RawConfig,
display: &str,
resolved: &[ResolvedRule],
options: LoadOptions<'_>,
) -> Result<Config, ConfigError> {
let overrides = parse_severity_overrides(&raw.severity, display)?;
let mut declared = BTreeSet::new();
for namespace in &raw.namespaces {
RuleId::namespace_from_str(namespace).map_err(|e| ConfigError::Shape {
path: display.to_owned(),
detail: format!("`namespaces` contains an invalid entry: {e}"),
})?;
if namespace == Namespace::LANEKEEP {
return Err(ConfigError::Shape {
path: display.to_owned(),
detail: "`lanekeep` is reserved for rules shipped with lanekeep — a rule's \
origin should be readable from its ID"
.to_owned(),
});
}
declared.insert(namespace.clone());
}
let mut limits = Limits::default();
if let Some(ms) = raw.timeouts.rule {
limits = limits.with_rule_timeout(Duration::from_millis(ms));
}
if let Some(ms) = raw.timeouts.global {
limits = limits.with_global_timeout(Duration::from_millis(ms));
}
if let Some(global) = options.global_timeout {
limits = limits.with_global_timeout(global);
}
let mut described = describe_components(root, resolved, display, limits, options.artifacts)?;
let mut rules = Vec::with_capacity(raw.rules.len());
for (index, rule) in raw.rules.into_iter().enumerate() {
match described.get_mut(index).and_then(Option::take) {
Some(hosted) => {
for rule in hosted {
rules.push(build_rule(
rule.raw,
index + 1,
display,
&overrides,
&declared,
Some(rule.component),
)?);
}
}
None => rules.push(build_rule(
rule,
index + 1,
display,
&overrides,
&declared,
None,
)?),
}
}
if let Some(position) = described.iter().position(Option::is_some) {
return Err(ConfigError::Rule {
position: position + 1,
path: display.to_owned(),
detail: "this component reached no rule — the entry module's rule array and the \
config's rule list are not the same length"
.to_owned(),
});
}
let components: Vec<&ComponentRule> = rules
.iter()
.filter_map(|rule| rule.component.as_ref())
.collect();
let ruleset_hash = hash_ruleset(sandbox, &components);
let config_hash = hash_config(&raw.include, &raw.exclude, &overrides, &limits, resolved);
Ok(Config {
include: raw.include,
exclude: raw.exclude,
rules,
limits,
ruleset_hash,
config_hash,
})
}
fn parse_severity_overrides(
raw: &BTreeMap<String, String>,
display: &str,
) -> Result<BTreeMap<RuleId, Severity>, ConfigError> {
raw.iter()
.map(|(id, severity)| {
let id = id.parse::<RuleId>().map_err(|e| ConfigError::Shape {
path: display.to_owned(),
detail: format!("in `severity`: {e}"),
})?;
let severity = severity
.parse::<Severity>()
.map_err(|e| ConfigError::Shape {
path: display.to_owned(),
detail: format!("in `severity` for `{id}`: {e}"),
})?;
Ok((id, severity))
})
.collect()
}
fn describe_components(
root: &RuleRoot,
resolved: &[ResolvedRule],
display: &str,
limits: Limits,
artifacts: Option<&Path>,
) -> Result<Vec<Option<Vec<Described>>>, ConfigError> {
let mut described: Vec<Option<Vec<Described>>> = resolved.iter().map(|_| None).collect();
if !resolved.iter().any(|rule| rule.reference.is_component()) {
return Ok(described);
}
let fail = |position: usize, detail: String| ConfigError::Rule {
position: position + 1,
path: display.to_owned(),
detail,
};
let broken = |detail: String| ConfigError::Shape {
path: display.to_owned(),
detail,
};
let engine = WasmEngine::new().map_err(|e| broken(e.to_string()))?;
let mut set = RuleSet::new(&engine).map_err(|e| broken(e.to_string()))?;
let loader = artifacts.map_or_else(
lanekeep_wasm::ComponentLoader::without_cache,
lanekeep_wasm::ComponentLoader::for_project_root,
);
let compiled = compile_components(
root,
resolved,
&engine,
&loader,
COMPILE_BUDGET_PER_COMPONENT,
)
.map_err(|(position, detail)| fail(position, detail))?;
let clock = RunClock::start(limits.global_timeout);
let mut added = Vec::new();
for entry in &compiled {
let position = entry.position;
let rule = &resolved[position];
let options = &entry.options;
let ids = hosted_rules(&engine, limits, &clock, &entry.admitted)
.map_err(|e| fail(position, e.to_string()))?;
if let Err(detail) = no_rules_detail(&ids, &rule.specifier) {
return Err(fail(position, detail));
}
let wanted = contributed(&ids, entry.only, &rule.specifier)
.map_err(|detail| fail(position, detail))?;
for (index, id) in wanted {
let slot = set
.add(&id, &entry.admitted, index, options.clone())
.map_err(|e| fail(position, e.to_string()))?;
added.push((
position,
slot,
id,
ComponentRule {
path: entry.origin.clone(),
index,
options: options.clone(),
bytes: entry.bytes.clone(),
source_map: entry.source_map.clone(),
counted_in_ruleset_hash: true,
},
));
}
}
let mut runtime = WasmRuntime::for_rules(engine, std::sync::Arc::new(set), limits, clock);
for (position, slot, enumerated, component) in added {
let metadata = runtime
.metadata(slot)
.map_err(|e| fail(position, e.to_string()))?;
if metadata.id != enumerated {
return Err(fail(
position,
format!(
"`{}` enumerates a rule as `{enumerated}` and that rule's metadata calls \
it `{}` — a component has to answer its own id the same way twice",
rule_specifier(resolved, position),
metadata.id
),
));
}
let has_check = runtime
.has_check(slot)
.map_err(|e| fail(position, e.to_string()))?;
let has_reduce = runtime
.has_reduce(slot)
.map_err(|e| fail(position, e.to_string()))?;
if let Some(entry) = described.get_mut(position) {
entry.get_or_insert_with(Vec::new).push(Described {
raw: raw_rule_from(metadata, has_check, has_reduce),
component,
});
}
}
Ok(described)
}
fn no_rules_detail(ids: &[String], specifier: &str) -> Result<(), String> {
if ids.is_empty() {
return Err(format!(
"`{specifier}` is a component that hosts no rules — there is nothing for this entry \
to run"
));
}
Ok(())
}
const COMPILE_BUDGET_PER_COMPONENT: Duration = Duration::from_mins(10);
struct Compiled {
position: usize,
origin: PathBuf,
bytes: ComponentBytes,
source_map: Option<ComponentBytes>,
only: Option<u32>,
options: String,
admitted: std::sync::Arc<lanekeep_wasm::Loaded>,
}
fn compile_components(
root: &RuleRoot,
resolved: &[ResolvedRule],
engine: &std::sync::Arc<WasmEngine>,
loader: &lanekeep_wasm::ComponentLoader,
budget: Duration,
) -> Result<Vec<Compiled>, (usize, String)> {
let started = std::time::Instant::now();
let mut compiled = Vec::new();
let mut memo: HashMap<[u8; 32], std::sync::Arc<lanekeep_wasm::Loaded>> = HashMap::new();
for (position, rule) in resolved.iter().enumerate() {
let Some(ComponentSource {
origin,
bytes,
only,
source_map,
}) = component_bytes(root, rule).map_err(|detail| (position, detail))?
else {
continue;
};
let options = rule
.options
.as_ref()
.map_or_else(|| "null".to_owned(), json::literal);
let identity = *blake3::hash(bytes.as_slice()).as_bytes();
let admitted = if let Some(existing) = memo.get(&identity) {
std::sync::Arc::clone(existing)
} else {
let fresh = std::sync::Arc::new(
loader
.load_mapped(
engine,
&rule.specifier,
bytes.as_slice(),
source_map.as_ref().map(ComponentBytes::as_slice),
)
.map_err(|e| (position, e.to_string()))?,
);
memo.insert(identity, std::sync::Arc::clone(&fresh));
fresh
};
compiled.push(Compiled {
position,
origin,
bytes,
source_map,
only,
options,
admitted,
});
if let Some(detail) = compile_overrun(started.elapsed(), compiled.len(), budget) {
return Err((position, detail));
}
}
Ok(compiled)
}
fn compile_overrun(elapsed: Duration, compiled: usize, budget: Duration) -> Option<String> {
let allowed = budget.saturating_mul(u32::try_from(compiled).unwrap_or(u32::MAX));
if elapsed <= allowed {
return None;
}
Some(format!(
"compiling the rule components took {elapsed:.1?}, past the {allowed:.1?} allowed for \
{compiled} of them\n \
this is the cost of turning WebAssembly into machine code and not of running any rule, \
so narrowing what is checked will not help\n \
a warm `.lanekeep/components` skips it entirely — if this recurs on every run, that \
directory is not writable"
))
}
fn contributed(
ids: &[String],
only: Option<u32>,
specifier: &str,
) -> Result<Vec<(u32, String)>, String> {
let Some(index) = only else {
return ids
.iter()
.enumerate()
.map(|(index, id)| {
u32::try_from(index)
.map_err(|_| format!("`{specifier}` lists more rules than an index can name"))
.map(|index| (index, id.clone()))
})
.collect();
};
let declared = ids.get(index as usize).ok_or_else(|| {
format!(
"`{specifier}` is recorded at index {index} of a component hosting {} rule(s) — \
the built-in table and the component disagree",
ids.len()
)
})?;
Ok(vec![(index, declared.clone())])
}
fn rule_specifier(resolved: &[ResolvedRule], position: usize) -> &str {
resolved
.get(position)
.map_or("", |rule| rule.specifier.as_str())
}
fn hosted_rules(
engine: &std::sync::Arc<WasmEngine>,
limits: Limits,
clock: &std::sync::Arc<RunClock>,
admitted: &lanekeep_wasm::Loaded,
) -> Result<Vec<String>, lanekeep_wasm::WasmError> {
let mut probe = WasmRuntime::new(
std::sync::Arc::clone(engine),
limits,
std::sync::Arc::clone(clock),
)?;
let instance = probe.instantiate(admitted)?;
probe.call_rules(&instance)
}
struct ComponentSource {
origin: PathBuf,
bytes: ComponentBytes,
only: Option<u32>,
source_map: Option<ComponentBytes>,
}
fn component_bytes(
root: &RuleRoot,
rule: &ResolvedRule,
) -> Result<Option<ComponentSource>, String> {
match &rule.reference {
RuleReference::BuiltinComponent(name) => {
let (bytes, index) = root.builtin_component(name).ok_or_else(|| {
format!(
"`lanekeep/{name}` was resolved as a built-in component and this build has \
no component by that name"
)
})?;
Ok(Some(ComponentSource {
origin: PathBuf::from(format!("lanekeep/{name}")),
bytes: bytes.to_vec().into(),
only: Some(index),
source_map: root
.builtin_component_map(name)
.map(|map| map.to_vec().into()),
}))
}
RuleReference::Component(path) => {
let confined = root.confine(&rule.specifier, path).map_err(|e| match e {
ResolveError::EscapesRoot { .. } => format!(
"`{}` resolves outside the rules root, and a rule component must sit \
inside it",
rule.specifier
),
ResolveError::Unreadable { detail, .. } => {
format!("cannot read `{}`: {detail}", path.display())
}
other => other.to_string(),
})?;
let bytes: ComponentBytes = std::fs::read(&confined)
.map_err(|e| format!("cannot read `{}`: {e}", confined.display()))?
.into();
Ok(Some(ComponentSource {
origin: confined,
bytes,
only: None,
source_map: None,
}))
}
RuleReference::Builtin(_) | RuleReference::Module(_) => Ok(None),
}
}
struct Described {
raw: RawRule,
component: ComponentRule,
}
fn raw_rule_from(
metadata: lanekeep_wasm::bindings::types::RuleMetadata,
has_check: bool,
has_reduce: bool,
) -> RawRule {
RawRule {
id: Some(metadata.id),
language: Some(RawLanguages::Many(metadata.languages)),
severity: Some(metadata.severity),
card: Some(RawCard {
message: Some(metadata.card.message),
remediation: Some(metadata.card.remediation),
examples: Some(RawExamples {
bad: Some(metadata.card.examples.bad),
good: Some(metadata.card.examples.good),
}),
}),
query: Some(metadata.query),
gates: Gates {
path_matches: metadata.gates.path_matches,
path_not_matches: metadata.gates.path_not_matches,
file_contains: metadata.gates.file_contains,
file_not_contains: metadata.gates.file_not_contains,
},
timeout: metadata.timeout,
has_check,
has_reduce,
}
}
fn build_rule(
raw: RawRule,
position: usize,
display: &str,
overrides: &BTreeMap<RuleId, Severity>,
declared: &BTreeSet<String>,
component: Option<ComponentRule>,
) -> Result<RuleSpec, ConfigError> {
let fail = |detail: String| ConfigError::Rule {
position,
path: display.to_owned(),
detail,
};
let id = raw
.id
.ok_or_else(|| fail("missing `id`".to_owned()))?
.parse::<RuleId>()
.map_err(|e| fail(e.to_string()))?;
if !id.namespace().is_built_in() && !declared.contains(id.namespace().as_str()) {
let mut known: Vec<String> = Namespace::built_ins()
.iter()
.map(|n| format!("`{n}`"))
.collect();
known.extend(declared.iter().map(|n| format!("`{n}`")));
return Err(fail(format!(
"rule namespace `{}` is not declared — add it to `namespaces` in the config, \
or use one of {}",
id.namespace(),
known.join(", ")
)));
}
if !raw.has_check {
return Err(fail(format!(
"`{id}` has no `check` function — a rule without one can never report anything"
)));
}
let query = raw
.query
.ok_or_else(|| fail(format!("`{id}` has no `query`")))?;
if query.trim().is_empty() {
return Err(fail(format!("`{id}` has an empty `query`")));
}
let card = raw
.card
.ok_or_else(|| fail(format!("`{id}` has no `card`")))?;
let examples = card.examples.unwrap_or(RawExamples {
bad: None,
good: None,
});
let card = RuleCard {
message: card.message.unwrap_or_default(),
remediation: card.remediation.unwrap_or_default(),
examples: Examples {
bad: examples.bad.unwrap_or_default(),
good: examples.good.unwrap_or_default(),
},
};
card.validate()
.map_err(|problems| fail(format!("`{id}` has an unusable card: {problems:?}")))?;
let declared = raw
.severity
.map(|s| s.parse::<Severity>())
.transpose()
.map_err(|e| fail(format!("`{id}`: {e}")))?
.unwrap_or(Severity::Error);
let languages = raw.language.map_or_else(
|| vec!["typescript".to_owned(), "tsx".to_owned()],
RawLanguages::into_vec,
);
if languages.is_empty() {
return Err(fail(format!(
"`{id}` names no language — a rule runs only on files whose language it names, so \
an empty list means it can never run"
)));
}
Ok(RuleSpec {
index: position - 1,
severity: overrides.get(&id).copied().unwrap_or(declared),
id,
languages,
card,
query,
gates: raw.gates,
timeout: raw.timeout.map(Duration::from_millis),
has_reduce: raw.has_reduce,
component,
})
}
fn hash_ruleset(sandbox: &Sandbox, components: &[&ComponentRule]) -> Hash {
let mut hasher = blake3::Hasher::new();
hasher.update(b"lanekeep-ruleset-v2");
if let Some(loaded) = sandbox.loaded_modules() {
for (path, source) in loaded.borrow().iter() {
hasher.update(path.to_string_lossy().as_bytes());
hasher.update(&[0]);
hasher.update(source.as_bytes());
hasher.update(&[0]);
}
}
let mut distinct: Vec<&[u8]> = components
.iter()
.map(|component| component.bytes.as_slice())
.collect();
distinct.sort_unstable();
distinct.dedup();
hasher.update(b"components");
length_prefixed(&mut hasher, &(distinct.len() as u64).to_le_bytes());
for bytes in &distinct {
length_prefixed(&mut hasher, bytes);
}
let mut rules: Vec<(usize, u32, &str)> = components
.iter()
.map(|component| {
let bytes = component.bytes.as_slice();
let at = match distinct.binary_search(&bytes) {
Ok(at) | Err(at) => at,
};
(at, component.index, component.options.as_str())
})
.collect();
rules.sort_unstable();
rules.dedup();
hasher.update(b"rules");
length_prefixed(&mut hasher, &(rules.len() as u64).to_le_bytes());
for (component, index, options) in rules {
hasher.update(&(component as u64).to_le_bytes());
hasher.update(&index.to_le_bytes());
length_prefixed(&mut hasher, options.as_bytes());
}
*hasher.finalize().as_bytes()
}
fn length_prefixed(hasher: &mut blake3::Hasher, bytes: &[u8]) {
hasher.update(&u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_le_bytes());
hasher.update(bytes);
}
fn hash_config(
include: &[String],
exclude: &[String],
severity: &BTreeMap<RuleId, Severity>,
limits: &Limits,
resolved: &[ResolvedRule],
) -> Hash {
let mut hasher = blake3::Hasher::new();
hasher.update(b"lanekeep-config-v1");
for (label, globs) in [
(b"include".as_slice(), include),
(b"exclude".as_slice(), exclude),
] {
hasher.update(label);
let mut sorted: Vec<&String> = globs.iter().collect();
sorted.sort();
for glob in sorted {
hasher.update(glob.as_bytes());
hasher.update(&[0]);
}
}
hasher.update(b"severity");
for (id, level) in severity {
hasher.update(id.to_string().as_bytes());
hasher.update(&[0]);
hasher.update(level.as_str().as_bytes());
hasher.update(&[0]);
}
hasher.update(b"limits");
for value in [
limits.rule_timeout.as_millis(),
limits.global_timeout.as_millis(),
limits.memory_bytes as u128,
] {
hasher.update(&value.to_le_bytes());
}
hasher.update(b"rules");
for rule in resolved {
length_prefixed(&mut hasher, rule.specifier.as_bytes());
if let Some(options) = &rule.options {
hasher.update(&[1]);
length_prefixed(&mut hasher, json::literal(options).as_bytes());
} else {
hasher.update(&[0]);
}
}
*hasher.finalize().as_bytes()
}
fn relative_specifier(root: &Path, file: &Path) -> Option<String> {
let file = file.canonicalize().ok()?;
let relative = file.strip_prefix(root).ok()?;
let joined = relative
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
Some(format!("./{joined}"))
}
pub fn sandbox_for(
root: &RuleRoot,
typescript: std::sync::Arc<dyn lanekeep_js::Language>,
javascript: std::sync::Arc<dyn lanekeep_js::Language>,
) -> Result<Sandbox, ConfigError> {
let limits = Limits::default();
Sandbox::with_modules(
limits,
RunClock::start(limits.global_timeout),
root.clone(),
typescript,
javascript,
)
.map_err(|e| ConfigError::Unreadable {
path: root.path().display().to_string(),
detail: e.to_string(),
})
}
#[must_use]
pub fn default_config_paths(project_root: &Path) -> Vec<PathBuf> {
[
"lanekeep.json",
"lanekeep.config.ts",
"lanekeep.config.js",
"lanekeep.config.mjs",
]
.iter()
.map(|name| project_root.join(name))
.collect()
}
#[cfg(test)]
mod tests {
use std::fs;
use std::sync::Arc;
use lanekeep_lang_js::{JavaScript, TypeScript};
use super::*;
struct Fixture {
dir: PathBuf,
}
impl Fixture {
fn new(name: &str, files: &[(&str, &str)]) -> Self {
let dir = std::env::temp_dir().join(format!("lanekeep-config-{name}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("creates dir");
let fixture = Self { dir };
fixture.write_all(files);
fixture
}
fn write_all(&self, files: &[(&str, &str)]) {
for (path, contents) in files {
let full = self.dir.join(path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).expect("creates parent");
}
fs::write(&full, contents).expect("writes");
}
}
fn load_config(&self) -> Result<Config, ConfigError> {
self.load_named("lanekeep.config.ts")
}
fn load_json(&self) -> Result<Config, ConfigError> {
self.load_named("lanekeep.json")
}
fn load_named(&self, name: &str) -> Result<Config, ConfigError> {
load_from(&self.dir, name)
}
fn empty_sandbox(&self) -> Sandbox {
let root = RuleRoot::new(&self.dir).expect("canonicalizes");
sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox")
}
fn write_component(&self, at: &str, fixture: &str) {
let from = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../lanekeep-wasm/tests/fixtures")
.join(format!("{fixture}.wasm"));
let full = self.dir.join(at);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).expect("creates parent");
}
fs::copy(&from, &full).expect("the fixture ships");
}
fn component(&self, name: &str) -> ComponentRule {
self.component_at(name, 0)
}
fn component_at(&self, name: &str, index: u32) -> ComponentRule {
let path = self.dir.join(name);
let bytes = fs::read(&path).expect("the component file is where the test put it");
ComponentRule {
path,
index,
options: "null".to_owned(),
bytes: bytes.into(),
source_map: None,
counted_in_ruleset_hash: true,
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
fn load_from(dir: &Path, name: &str) -> Result<Config, ConfigError> {
let root = RuleRoot::new(dir).expect("canonicalizes");
let sandbox =
sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox");
load(&sandbox, &root, &dir.join(name))
}
fn load_with_components(
dir: &Path,
name: &str,
components: lanekeep_js::BuiltinComponent,
) -> Result<Config, ConfigError> {
let root = RuleRoot::new(dir)
.expect("canonicalizes")
.with_builtin_components(components);
let sandbox =
sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox");
load(&sandbox, &root, &dir.join(name))
}
fn built_in_component_bytes() -> &'static [u8] {
static BYTES: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
BYTES.get_or_init(|| {
fs::read(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../lanekeep-wasm/tests/fixtures/metadata.wasm"),
)
.expect("the fixture ships")
})
}
fn shared_component_bytes() -> &'static [u8] {
static BYTES: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
BYTES.get_or_init(|| {
fs::read(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../lanekeep-wasm/tests/fixtures/two-rules.wasm"),
)
.expect("the fixture ships")
})
}
fn big_component_bytes() -> &'static [u8] {
static BYTES: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
BYTES.get_or_init(|| {
fs::read(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../lanekeep-wasm/tests/fixtures/js-globals.wasm"),
)
.expect("the fixture ships")
})
}
fn two_faced_component_bytes() -> &'static [u8] {
static BYTES: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
BYTES.get_or_init(|| {
fs::read(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../lanekeep-wasm/tests/fixtures/two-faced.wasm"),
)
.expect("the fixture ships")
})
}
fn built_in_components(name: &str) -> Option<(&'static [u8], u32)> {
match name {
"metadata" => Some((built_in_component_bytes(), 0)),
"shared-first" => Some((shared_component_bytes(), 0)),
"shared-second" => Some((shared_component_bytes(), 1)),
"shared-missing" => Some((shared_component_bytes(), 7)),
"two-faced" => Some((two_faced_component_bytes(), 0)),
"big" => Some((big_component_bytes(), 1)),
_ => None,
}
}
fn rule(id: &str) -> String {
format!(
"import {{ defineRule }} from 'lanekeep';\n\
export default defineRule({{\n\
id: '{id}',\n\
query: '(identifier) @id',\n\
card: {{ message: 'no', remediation: 'do this', examples: {{ bad: 'a', good: 'b' }} }},\n\
check(ctx, m) {{ ctx.report(m.id); }},\n\
}});\n"
)
}
fn factory_rule(id: &str) -> String {
format!(
"import {{ defineRule }} from 'lanekeep';\n\
export default (options) => defineRule({{\n\
id: '{id}',\n\
query: '(identifier) @id',\n\
card: {{ message: 'no', remediation: 'do this', examples: {{ bad: 'a', good: 'b' }} }},\n\
check(ctx, m) {{ ctx.report(m.id); }},\n\
}});\n"
)
}
fn config_with(body: &str) -> String {
format!(
"import {{ defineConfig }} from 'lanekeep';\n\
import rule from './rule';\n\
export default defineConfig({{ {body} }});\n"
)
}
#[test]
fn loads_a_valid_config() {
let fixture = Fixture::new(
"valid",
&[
("rule.ts", &rule("local/example")),
(
"lanekeep.config.ts",
&config_with(
"include: ['src/**/*.ts'], exclude: ['**/*.test.ts'], rules: [rule]",
),
),
],
);
let config = fixture.load_config().expect("loads");
assert_eq!(config.include, ["src/**/*.ts"]);
assert_eq!(config.exclude, ["**/*.test.ts"]);
assert_eq!(config.rules.len(), 1);
assert_eq!(config.rules[0].id.to_string(), "local/example");
assert_eq!(config.rules[0].card.message, "no");
assert!(!config.rules[0].has_reduce);
}
#[test]
fn a_declared_namespace_is_accepted() {
let fixture = Fixture::new(
"declared-namespace",
&[
("rule.ts", &rule("pera/no-numeric-sizes")),
(
"lanekeep.config.ts",
&config_with("namespaces: ['pera'], rules: [rule]"),
),
],
);
let config = fixture.load_config().expect("loads");
assert_eq!(config.rules[0].id.to_string(), "pera/no-numeric-sizes");
assert!(!config.rules[0].id.is_built_in());
}
#[test]
fn an_undeclared_namespace_is_rejected() {
let fixture = Fixture::new(
"undeclared-namespace",
&[
("rule.ts", &rule("lanekep/no-default-export")),
("lanekeep.config.ts", &config_with("rules: [rule]")),
],
);
let error = fixture
.load_config()
.expect_err("an undeclared namespace should be refused")
.to_string();
assert!(error.contains("lanekep"), "{error}");
assert!(
error.contains("namespaces"),
"should say how to fix it: {error}"
);
}
#[test]
fn the_lanekeep_namespace_cannot_be_claimed() {
let fixture = Fixture::new(
"reserved-namespace",
&[
("rule.ts", &rule("local/example")),
(
"lanekeep.config.ts",
&config_with("namespaces: ['lanekeep'], rules: [rule]"),
),
],
);
let error = fixture
.load_config()
.expect_err("claiming the reserved namespace should be refused")
.to_string();
assert!(error.contains("reserved"), "{error}");
}
#[test]
fn a_rule_defaults_to_both_typescript_dialects() {
let fixture = Fixture::new(
"default-languages",
&[
("rule.ts", &rule("local/example")),
("lanekeep.config.ts", &config_with("rules: [rule]")),
],
);
let config = fixture.load_config().expect("loads");
assert_eq!(config.rules[0].languages, ["typescript", "tsx"]);
}
#[test]
fn a_rule_may_declare_one_language_or_several() {
for (declaration, expected) in [
("language: 'tsx',", vec!["tsx"]),
(
"language: ['typescript', 'tsx'],",
vec!["typescript", "tsx"],
),
] {
let module = format!(
"import {{ defineRule }} from 'lanekeep';\n\
export default defineRule({{\n\
id: 'local/example',\n\
{declaration}\n\
query: '(identifier) @id',\n\
card: {{ message: 'no', remediation: 'do this', examples: {{ bad: 'a', good: 'b' }} }},\n\
check(ctx, m) {{ ctx.report(m.id); }},\n\
}});\n"
);
let fixture = Fixture::new(
"language-forms",
&[
("rule.ts", &module),
("lanekeep.config.ts", &config_with("rules: [rule]")),
],
);
let config = fixture.load_config().expect("loads");
assert_eq!(config.rules[0].languages, expected, "{declaration}");
}
}
#[test]
fn a_rule_without_a_check_function_is_rejected() {
let fixture = Fixture::new(
"no-check",
&[
(
"rule.ts",
"import { defineRule } from 'lanekeep';\n\
export default defineRule({\n\
id: 'local/typo',\n\
query: '(identifier) @id',\n\
card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
onMatch(ctx, m) {},\n\
});\n",
),
("lanekeep.config.ts", &config_with("rules: [rule]")),
],
);
let err = fixture.load_config().expect_err("must be rejected");
let rendered = err.to_string();
assert!(rendered.contains("check"), "{rendered}");
assert!(rendered.contains("never report"), "{rendered}");
}
#[test]
fn a_rule_with_a_bare_id_is_rejected() {
let fixture = Fixture::new(
"bare-id",
&[
("rule.ts", &rule("example")),
("lanekeep.config.ts", &config_with("rules: [rule]")),
],
);
let rendered = fixture
.load_config()
.expect_err("must be rejected")
.to_string();
assert!(rendered.contains("namespace"), "{rendered}");
}
#[test]
fn a_rule_with_an_unusable_card_is_rejected() {
let fixture = Fixture::new(
"bad-card",
&[
(
"rule.ts",
"import { defineRule } from 'lanekeep';\n\
export default defineRule({\n\
id: 'local/empty',\n\
query: '(identifier) @id',\n\
card: { message: '', remediation: '', examples: { bad: '', good: '' } },\n\
check() {},\n\
});\n",
),
("lanekeep.config.ts", &config_with("rules: [rule]")),
],
);
assert!(fixture.load_config().is_err());
}
#[test]
fn a_missing_default_export_says_so() {
let fixture = Fixture::new(
"no-default",
&[
("rule.ts", &rule("local/x")),
("lanekeep.config.ts", "export const notDefault = 1;\n"),
],
);
let rendered = fixture
.load_config()
.expect_err("must be rejected")
.to_string();
assert!(rendered.contains("default"), "{rendered}");
}
#[test]
fn a_default_export_that_is_not_an_object_says_so() {
let fixture = Fixture::new(
"default-not-object",
&[
("rule.ts", &rule("local/x")),
("lanekeep.config.ts", "export default 42;\n"),
],
);
let rendered = fixture
.load_config()
.expect_err("must be rejected")
.to_string();
assert!(rendered.contains("export default"), "{rendered}");
}
#[test]
fn config_severity_overrides_what_the_rule_declares() {
let fixture = Fixture::new(
"severity",
&[
("rule.ts", &rule("local/example")),
(
"lanekeep.config.ts",
&config_with("rules: [rule], severity: { 'local/example': 'warn' }"),
),
],
);
let config = fixture.load_config().expect("loads");
assert_eq!(config.rules[0].severity, Severity::Warn);
}
#[test]
fn timeouts_fall_back_to_the_defaults() {
let fixture = Fixture::new(
"timeouts-default",
&[
("rule.ts", &rule("local/example")),
("lanekeep.config.ts", &config_with("rules: [rule]")),
],
);
let config = fixture.load_config().expect("loads");
assert_eq!(config.limits, Limits::default());
}
#[test]
fn timeouts_can_be_overridden() {
let fixture = Fixture::new(
"timeouts-set",
&[
("rule.ts", &rule("local/example")),
(
"lanekeep.config.ts",
&config_with("rules: [rule], timeouts: { rule: 2000, global: 30000 }"),
),
],
);
let config = fixture.load_config().expect("loads");
assert_eq!(config.limits.rule_timeout, Duration::from_secs(2));
assert_eq!(config.limits.global_timeout, Duration::from_secs(30));
}
#[test]
fn a_component_reference_resolves_to_a_spec_carrying_its_own_metadata() {
let fixture = Fixture::new("component-metadata", &[]);
fixture.write_component("rules/metadata.wasm", "metadata");
fixture.write_all(&[(
"lanekeep.json",
r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
"rules": ["./rules/metadata.wasm"]}"#,
)]);
let config = fixture
.load_json()
.expect("a component reference is resolvable");
let rule = &config.rules[0];
assert_eq!(rule.id.to_string(), "fixture/metadata");
assert_eq!(rule.query, "(call_expression) @call");
assert_eq!(rule.languages, ["rust"]);
assert_eq!(rule.card.message, "a fixture");
assert_eq!(rule.card.remediation, "do the other thing");
assert_eq!(rule.gates.path_matches, ["src/**/*.rs"]);
assert_eq!(rule.gates.path_not_matches, ["**/generated/**"]);
assert_eq!(rule.gates.file_contains, ["call"]);
assert_eq!(rule.gates.file_not_contains, ["skip"]);
assert_eq!(rule.timeout, Some(Duration::from_millis(1500)));
assert!(
!rule.has_reduce,
"the fixture answers `has-reduce` with false, and the config must take that \
answer rather than assuming one"
);
let component = rule
.component
.as_ref()
.expect("the bytes travel with the rule");
assert_eq!(
component.bytes.as_slice(),
fs::read(fixture.dir.join("rules/metadata.wasm"))
.expect("the fixture is there")
.as_slice(),
"the rule carries the component it was described from"
);
assert!(
component.counted_in_ruleset_hash(),
"a component `load` resolved must be counted in `ruleset_hash`"
);
}
#[test]
fn only_a_load_given_a_project_root_caches_what_it_compiled() {
let files = &[(
"lanekeep.json",
r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
"rules": ["./rules/metadata.wasm"]}"#,
)];
let plain = Fixture::new("artifact-cache-absent", files);
plain.write_component("rules/metadata.wasm", "metadata");
plain.load_json().expect("the component resolves");
assert!(
!plain.dir.join(lanekeep_wasm::COMPONENT_CACHE_PATH).exists(),
"`load` names no project root, so it must not write a cache directory into one"
);
let cached = Fixture::new("artifact-cache-present", files);
cached.write_component("rules/metadata.wasm", "metadata");
let root = RuleRoot::new(&cached.dir).expect("canonicalizes");
let sandbox =
sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox");
load_with(
&sandbox,
&root,
&cached.dir.join("lanekeep.json"),
LoadOptions {
artifacts: Some(&cached.dir),
..LoadOptions::default()
},
)
.expect("the component resolves");
let artifacts = cached.dir.join(lanekeep_wasm::COMPONENT_CACHE_PATH);
let written: Vec<_> = fs::read_dir(&artifacts)
.expect("the cache directory is there")
.filter_map(|entry| entry.ok().map(|e| e.path()))
.filter(|path| path.extension().is_some_and(|ext| ext == "cwasm"))
.collect();
assert_eq!(
written.len(),
1,
"one component was described, so one artifact should be cached; found {written:?}"
);
}
#[test]
fn compiling_a_component_is_not_charged_to_the_run_budget() {
let fixture = Fixture::new("config-compile-unclocked", &[]);
fixture.write_all(&[(
"lanekeep.json",
r#"{"include": ["**/*.ts"], "namespaces": ["probe"],
"timeouts": {"global": 1000},
"rules": ["lanekeep/big"]}"#,
)]);
let config = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
.expect("a 1 s budget bounds guest work, and compiling is not guest work");
assert_eq!(
config.rules.len(),
1,
"the component's rule has to have been described, or nothing was clocked at all"
);
assert_eq!(config.rules[0].id.to_string(), "probe/context");
}
#[test]
fn the_compile_budget_is_a_comparison_against_a_scaling_allowance() {
const BUDGET: Duration = Duration::from_micros(250);
assert_eq!(compile_overrun(Duration::ZERO, 1, BUDGET), None);
assert_eq!(compile_overrun(BUDGET, 1, BUDGET), None);
assert!(
compile_overrun(BUDGET + Duration::from_micros(1), 1, BUDGET).is_some(),
"a microsecond past the allowance is past it"
);
let three = BUDGET * 3;
assert_eq!(compile_overrun(three, 3, BUDGET), None);
assert!(compile_overrun(three, 2, BUDGET).is_some());
assert!(
compile_overrun(three + Duration::from_micros(1), 3, BUDGET).is_some(),
"three components get three allowances and not a fourth"
);
assert!(compile_overrun(Duration::from_micros(1), 0, BUDGET).is_some());
}
#[test]
fn the_compile_budget_message_says_what_it_is_and_what_will_not_help() {
const BUDGET: Duration = Duration::from_micros(250);
let detail = compile_overrun(BUDGET * 9, 2, BUDGET)
.expect("nine allowances against two components is an overrun");
assert!(
detail.contains("compiling the rule components took"),
"{detail}"
);
assert!(detail.contains("2 of them"), "{detail}");
assert!(
detail.contains("not of running any rule"),
"the message has to say this is not a rule's fault: {detail}"
);
assert!(
detail.contains("narrowing what is checked will not help"),
"and that the other budget's advice does not apply: {detail}"
);
assert!(
detail.contains(".lanekeep/components"),
"and where the remedy actually is: {detail}"
);
}
#[test]
fn the_compilation_pass_checks_its_budget() {
let fixture = Fixture::new("config-compile-budget-call", &[]);
fixture.write_component("rules/probe.wasm", "world-shape");
let root = RuleRoot::new(&fixture.dir).expect("canonicalizes");
let engine = WasmEngine::new().expect("the shipped configuration builds an engine");
let loader = lanekeep_wasm::ComponentLoader::without_cache();
let resolved = vec![ResolvedRule {
specifier: "./rules/probe.wasm".to_owned(),
reference: RuleReference::Component(root.path().join("rules/probe.wasm")),
options: None,
}];
let Err((position, detail)) =
compile_components(&root, &resolved, &engine, &loader, Duration::ZERO)
else {
panic!("no compilation finishes in zero time");
};
assert_eq!(position, 0, "the diagnostic names the entry that overran");
assert!(
detail.contains("compiling the rule components took"),
"and it is the compilation diagnostic rather than a load failure: {detail}"
);
let compiled = compile_components(
&root,
&resolved,
&engine,
&loader,
COMPILE_BUDGET_PER_COMPONENT,
)
.expect("the same component compiles fine under the shipped budget");
assert_eq!(compiled.len(), 1);
}
#[test]
fn four_references_to_one_component_load_it_once() {
let fixture = Fixture::new("config-load-one-component", &[]);
fixture.write_component("rules/shared.wasm", "world-shape");
let root = RuleRoot::new(&fixture.dir).expect("canonicalizes");
let engine = WasmEngine::new().expect("the shipped configuration builds an engine");
let loader = lanekeep_wasm::ComponentLoader::without_cache();
let path = root.path().join("rules/shared.wasm");
let resolved: Vec<ResolvedRule> = (0..4)
.map(|_| ResolvedRule {
specifier: "./rules/shared.wasm".to_owned(),
reference: RuleReference::Component(path.clone()),
options: None,
})
.collect();
let compiled = compile_components(
&root,
&resolved,
&engine,
&loader,
COMPILE_BUDGET_PER_COMPONENT,
)
.expect("the shared component compiles");
assert_eq!(compiled.len(), 4, "one Compiled per reference");
assert_eq!(
loader.compilations(),
1,
"one component compiled once, not once per reference"
);
assert_eq!(
loader.embedded_loads(),
1,
"and deserialized once — the memo hands one Loaded to every reference"
);
assert!(
Arc::ptr_eq(&compiled[0].admitted, &compiled[1].admitted),
"the same Loaded is handed to the second reference"
);
assert!(
Arc::ptr_eq(&compiled[0].admitted, &compiled[3].admitted),
"and to every one after it"
);
}
#[test]
fn a_raised_global_timeout_governs_config_load_and_not_only_the_run() {
let files: &[(&str, &str)] = &[(
"lanekeep.json",
r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
"timeouts": {"global": 50},
"rules": [{"rule": "./rules/metadata.wasm", "options": {"burn": true}}]}"#,
)];
let fixture = Fixture::new("config-load-budget", files);
fixture.write_component("rules/metadata.wasm", "metadata");
let root = RuleRoot::new(&fixture.dir).expect("canonicalizes");
let sandbox =
sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox");
let config_path = fixture.dir.join("lanekeep.json");
let breached = load_with(&sandbox, &root, &config_path, LoadOptions::default())
.expect_err("50 ms is far below what the fixture's `configure` spends");
let text = breached.to_string();
assert!(
text.contains("budget"),
"the breach must be the budget rather than something incidental, got: {text}"
);
load_with(
&sandbox,
&root,
&config_path,
LoadOptions {
global_timeout: Some(Duration::from_secs(30)),
..LoadOptions::default()
},
)
.expect("a raised budget must reach the phase that breached under the lower one");
}
#[test]
fn a_built_in_that_ships_as_a_component_resolves_without_a_path() {
let fixture = Fixture::new("builtin-component-load", &[]);
fixture.write_all(&[(
"lanekeep.json",
r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
"rules": ["lanekeep/metadata"]}"#,
)]);
let config = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
.expect("a built-in component is resolvable by specifier");
let rule = &config.rules[0];
assert_eq!(rule.id.to_string(), "fixture/metadata");
assert_eq!(rule.query, "(call_expression) @call");
assert_eq!(rule.languages, ["rust"]);
let component = rule
.component
.as_ref()
.expect("a built-in component reaches the engine as a component");
assert_eq!(
component.bytes.as_slice(),
built_in_component_bytes(),
"the rule carries the embedded bytes it was described from"
);
assert_eq!(component.path, PathBuf::from("lanekeep/metadata"));
assert!(
!component.path.is_absolute(),
"a built-in's provenance must not look like a resolved path"
);
assert!(
component.counted_in_ruleset_hash(),
"a built-in component `load` resolved must be counted in `ruleset_hash`"
);
}
#[test]
fn a_built_in_contributes_the_one_rule_its_table_recorded() {
let fixture = Fixture::new("builtin-component-narrowed", &[]);
fixture.write_all(&[(
"lanekeep.json",
r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
"rules": ["lanekeep/shared-second"]}"#,
)]);
let config = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
.expect("a rule of a shared component is resolvable by specifier");
assert_eq!(
config.rules.len(),
1,
"one entry naming one rule of a two-rule component must produce one rule, not the \
component's whole list: {:?}",
config
.rules
.iter()
.map(|rule| rule.id.to_string())
.collect::<Vec<_>>()
);
let rule = &config.rules[0];
assert_eq!(
rule.id.to_string(),
"fixture/second",
"the reference is recorded at index 1 and index 0 is a rule that would run happily"
);
assert_eq!(
rule.component
.as_ref()
.expect("a built-in component reaches the engine as a component")
.index,
1,
"the engine dispatches on this, so it has to be the recorded index and not a \
position in the config"
);
assert_ne!(rule.query, "(call_expression) @0");
}
#[test]
fn each_rule_of_one_shared_component_is_reachable_as_itself() {
let fixture = Fixture::new("builtin-component-both", &[]);
fixture.write_all(&[(
"lanekeep.json",
r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
"rules": ["lanekeep/shared-first", "lanekeep/shared-second"]}"#,
)]);
let config = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
.expect("two rules of one artifact both resolve");
let ids: Vec<String> = config
.rules
.iter()
.map(|rule| rule.id.to_string())
.collect();
assert_eq!(ids, vec!["fixture/first", "fixture/second"]);
let indices: Vec<u32> = config
.rules
.iter()
.filter_map(|rule| rule.component.as_ref().map(|component| component.index))
.collect();
assert_eq!(indices, vec![0, 1], "each names its own slot");
let bytes: Vec<&[u8]> = config
.rules
.iter()
.filter_map(|rule| rule.component.as_ref().map(|c| c.bytes.as_slice()))
.collect();
assert_eq!(bytes.len(), 2);
assert_eq!(bytes[0], bytes[1], "two rules, one component");
}
#[test]
fn a_table_recording_an_index_its_component_does_not_have_is_refused() {
let fixture = Fixture::new("builtin-component-drifted", &[]);
fixture.write_all(&[(
"lanekeep.json",
r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
"rules": ["lanekeep/shared-missing"]}"#,
)]);
let error = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
.expect_err("a recorded index the component does not have cannot be dispatched");
let rendered = error.to_string();
assert!(
rendered.contains("lanekeep/shared-missing"),
"the refusal has to name the entry: {rendered}"
);
assert!(
rendered.contains("index 7"),
"and the index it could not find: {rendered}"
);
assert!(
rendered.contains("hosting 2 rule(s)"),
"and what the component actually hosts, which is the other half of a \
disagreement: {rendered}"
);
assert!(
rendered.contains("disagree"),
"the diagnostic is about two things not matching, not about a bad number: \
{rendered}"
);
}
#[test]
fn a_component_that_answers_two_different_ids_for_one_rule_is_refused() {
let fixture = Fixture::new("component-two-faced", &[]);
fixture.write_all(&[(
"lanekeep.json",
r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
"rules": ["lanekeep/two-faced"]}"#,
)]);
let error = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
.expect_err("a component whose two accounts of itself disagree cannot be loaded");
let rendered = error.to_string();
assert!(
rendered.contains("lanekeep/two-faced"),
"the refusal has to name the config entry, which is what a reader can act on: \
{rendered}"
);
assert!(
rendered.contains("fixture/enumerated"),
"and the id it was registered under: {rendered}"
);
assert!(
rendered.contains("fixture/described"),
"and the id its metadata answered, or a reader cannot see what disagreed: \
{rendered}"
);
}
#[test]
fn a_component_that_agrees_with_itself_is_not_refused() {
let fixture = Fixture::new("component-consistent", &[]);
fixture.write_all(&[(
"lanekeep.json",
r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
"rules": ["lanekeep/metadata"]}"#,
)]);
let config = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
.expect("a component whose two exports agree must load");
assert_eq!(config.rules[0].id.to_string(), "fixture/metadata");
}
#[test]
fn a_component_hosting_no_rules_is_refused_with_its_specifier() {
let error = no_rules_detail(&[], "./rules/empty.wasm")
.expect_err("an empty rule list is nothing to run");
assert!(
error.contains("./rules/empty.wasm"),
"the refusal has to name the entry, which is what a reader can act on: {error}"
);
assert!(
error.contains("hosts no rules"),
"and what is wrong with it: {error}"
);
}
#[test]
fn a_component_hosting_rules_is_not_refused() {
assert!(
no_rules_detail(&["fixture/one".to_owned()], "./rules/one.wasm").is_ok(),
"a non-empty id list is a component with something to run"
);
}
#[test]
fn the_same_specifier_is_a_module_in_a_build_where_no_component_ships() {
let fixture = Fixture::new("builtin-component-absent", &[]);
fixture.write_all(&[(
"lanekeep.json",
r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
"rules": ["lanekeep/metadata"]}"#,
)]);
let error = fixture
.load_json()
.expect_err("nothing ships under that name in this build");
let rendered = error.to_string();
assert!(
rendered.contains("lanekeep/metadata"),
"the refusal has to name the specifier: {rendered}"
);
}
#[test]
fn an_uncounted_component_is_not_counted_in_ruleset_hash() {
let component = ComponentRule::uncounted(
PathBuf::from("rules/mine.wasm"),
0,
"null".to_owned(),
b"\0asm".to_vec(),
);
assert!(
!component.counted_in_ruleset_hash(),
"bytes nobody hashed must not claim to be counted"
);
}
#[test]
fn a_typescript_rule_naming_no_language_is_refused() {
let fixture = Fixture::new(
"empty-languages-ts",
&[
(
"rule.ts",
"import { defineRule } from 'lanekeep';\n\
export default defineRule({\n\
id: 'local/silent',\n\
language: [],\n\
query: '(identifier) @id',\n\
card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
check(ctx, m) { ctx.report(m.id); },\n\
});\n",
),
("lanekeep.config.ts", &config_with("rules: [rule]")),
],
);
let error = fixture
.load_config()
.expect_err("a rule that can never run must not load");
let rendered = error.to_string();
assert!(rendered.contains("local/silent"), "{rendered}");
assert!(rendered.contains("names no language"), "{rendered}");
}
#[test]
fn a_component_naming_no_language_is_refused() {
let described = Described {
raw: raw_rule_from(
lanekeep_wasm::bindings::types::RuleMetadata {
id: "fixture/silent".to_owned(),
languages: Vec::new(),
severity: "error".to_owned(),
card: lanekeep_wasm::bindings::types::RuleCard {
message: "m".to_owned(),
remediation: "r".to_owned(),
examples: lanekeep_wasm::bindings::types::RuleExamples {
bad: "a".to_owned(),
good: "b".to_owned(),
},
},
query: "(call_expression) @call".to_owned(),
gates: lanekeep_wasm::bindings::types::RuleGates {
path_matches: Vec::new(),
path_not_matches: Vec::new(),
file_contains: Vec::new(),
file_not_contains: Vec::new(),
},
timeout: None,
},
true,
false,
),
component: ComponentRule {
path: PathBuf::from("silent.wasm"),
index: 0,
options: "null".to_owned(),
bytes: Vec::new().into(),
source_map: None,
counted_in_ruleset_hash: true,
},
};
let declared = BTreeSet::from(["fixture".to_owned()]);
let error = build_rule(
described.raw,
1,
"lanekeep.json",
&BTreeMap::new(),
&declared,
Some(described.component),
)
.expect_err("a component that can never run must not load");
let rendered = error.to_string();
assert!(rendered.contains("fixture/silent"), "{rendered}");
assert!(rendered.contains("names no language"), "{rendered}");
}
#[test]
fn a_component_is_held_to_the_same_card_and_query_a_typescript_rule_is() {
let fixture = Fixture::new("component-validated", &[]);
fixture.write_component("rules/probe.wasm", "world-shape");
fixture.write_all(&[(
"lanekeep.json",
r#"{"namespaces": ["fixture"], "rules": ["./rules/probe.wasm"]}"#,
)]);
let error = fixture
.load_json()
.expect_err("a probe is not a usable rule");
assert!(
matches!(error, ConfigError::Rule { position: 1, .. }),
"{error:?}"
);
assert!(
error.to_string().contains("fixture/world-shape"),
"the component's own id should name it: {error}"
);
}
#[test]
fn a_component_reference_may_not_traverse_out_of_the_rules_root() {
let fixture = Fixture::new("component-traversal", &[]);
fixture.write_component("secret.wasm", "metadata");
fs::create_dir_all(fixture.dir.join("project")).expect("creates the inner root");
for specifier in ["../secret.wasm", "../../secret.wasm", "./../secret.wasm"] {
fs::write(
fixture.dir.join("project/lanekeep.json"),
format!(r#"{{"namespaces": ["fixture"], "rules": ["{specifier}"]}}"#),
)
.expect("writes");
let error = load_from(&fixture.dir.join("project"), "lanekeep.json")
.expect_err("traversal must not resolve");
assert!(
matches!(error, ConfigError::Rule { position: 1, .. }),
"{specifier} gave {error:?}"
);
assert!(
error.to_string().contains("outside the rules root"),
"{specifier} gave {error}"
);
}
}
#[test]
fn a_component_reference_may_not_be_an_absolute_path() {
let fixture = Fixture::new("component-absolute", &[]);
fixture.write_component("outside.wasm", "metadata");
let outside = fixture.dir.join("outside.wasm");
let inner = fixture.dir.join("project");
fs::create_dir_all(&inner).expect("creates the inner root");
let forward = outside.display().to_string().replace('\\', "/");
let specifier = serde_json::to_string(&forward).expect("a path is a JSON string");
fs::write(
inner.join("lanekeep.json"),
format!(r#"{{"namespaces": ["fixture"], "rules": [{specifier}]}}"#),
)
.expect("writes");
let error = load_from(&inner, "lanekeep.json").expect_err("an absolute path is refused");
assert!(
error.to_string().contains("outside the rules root"),
"{error}"
);
}
#[cfg(unix)]
#[test]
fn a_component_reference_may_not_be_a_symlink_out_of_the_rules_root() {
let fixture = Fixture::new("component-symlink", &[]);
fixture.write_component("outside.wasm", "metadata");
let inner = fixture.dir.join("project");
fs::create_dir_all(&inner).expect("creates the inner root");
std::os::unix::fs::symlink(fixture.dir.join("outside.wasm"), inner.join("link.wasm"))
.expect("creates symlink");
fs::write(
inner.join("lanekeep.json"),
r#"{"namespaces": ["fixture"], "rules": ["./link.wasm"]}"#,
)
.expect("writes");
let error = load_from(&inner, "lanekeep.json").expect_err("a symlink out is refused");
assert!(
error.to_string().contains("outside the rules root"),
"{error}"
);
}
#[cfg(unix)]
#[test]
fn an_escaping_component_is_refused_before_its_bytes_are_read() {
let fixture = Fixture::new("component-escape-before-read", &[]);
fixture.write_component("outside.wasm", "metadata");
let outside = fixture.dir.join("outside.wasm");
fs::set_permissions(
&outside,
std::os::unix::fs::PermissionsExt::from_mode(0o000),
)
.expect("makes it unreadable");
let inner = fixture.dir.join("project");
fs::create_dir_all(&inner).expect("creates the inner root");
fs::write(
inner.join("lanekeep.json"),
r#"{"namespaces": ["fixture"], "rules": ["../outside.wasm"]}"#,
)
.expect("writes");
let error = load_from(&inner, "lanekeep.json").expect_err("refused");
let rendered = error.to_string();
assert!(
rendered.contains("outside the rules root"),
"the escape must be what stopped it, not the read: {rendered}"
);
assert!(
!rendered.contains("Permission denied"),
"nothing may be read before the reference is confined: {rendered}"
);
fs::set_permissions(
&outside,
std::os::unix::fs::PermissionsExt::from_mode(0o644),
)
.expect("restores");
}
#[test]
fn a_typescript_rule_after_a_component_keeps_its_own_index() {
let fixture = Fixture::new(
"component-mixed-order",
&[("second.ts", &rule("local/second"))],
);
fixture.write_component("rules/metadata.wasm", "metadata");
fixture.write_all(&[(
"lanekeep.json",
r#"{"namespaces": ["fixture"],
"rules": ["./rules/metadata.wasm", "./second"]}"#,
)]);
let config = fixture.load_json().expect("loads");
assert_eq!(config.rules[0].id.to_string(), "fixture/metadata");
assert_eq!(config.rules[0].index, 0);
assert_eq!(config.rules[1].id.to_string(), "local/second");
assert_eq!(
config.rules[1].index, 1,
"the TypeScript rule's index is its position in the array the engine indexes"
);
assert!(config.rules[1].component.is_none());
}
#[test]
fn one_component_describes_every_rule_it_hosts() {
let fixture = Fixture::new(
"component-many-rules",
&[("second.ts", &rule("local/last"))],
);
fixture.write_component("rules/two-rules.wasm", "two-rules");
fixture.write_all(&[(
"lanekeep.json",
r#"{"namespaces": ["fixture"],
"rules": ["./rules/two-rules.wasm", "./second"]}"#,
)]);
let config = fixture.load_json().expect("loads");
let ids: Vec<String> = config.rules.iter().map(|r| r.id.to_string()).collect();
assert_eq!(ids, ["fixture/first", "fixture/second", "local/last"]);
assert_eq!(config.rules[0].query, "(call_expression) @0");
assert_eq!(config.rules[1].query, "(call_expression) @1");
let first = config.rules[0]
.component
.as_ref()
.expect("a component-backed rule");
let second = config.rules[1]
.component
.as_ref()
.expect("a component-backed rule");
assert_eq!((first.index, second.index), (0, 1));
assert_eq!(
first.bytes, second.bytes,
"two rules of one component are one artifact, read once"
);
assert_eq!(config.rules[0].index, 0);
assert_eq!(config.rules[1].index, 0);
assert_eq!(
config.rules[2].index, 1,
"a rule after a multi-rule component still indexes the array the engine indexes"
);
assert!(config.rules[2].component.is_none());
}
#[test]
fn a_multi_rule_components_options_reach_each_of_its_rules() {
let fixture = Fixture::new("component-many-options", &[]);
fixture.write_component("rules/two-rules.wasm", "two-rules");
fixture.write_all(&[(
"lanekeep.json",
r#"{"namespaces": ["fixture"],
"rules": [{"rule": "./rules/two-rules.wasm", "options": {"tag": "alpha"}}]}"#,
)]);
let config = fixture.load_json().expect("loads");
let messages: Vec<&str> = config
.rules
.iter()
.map(|r| r.card.message.as_str())
.collect();
assert_eq!(
messages,
["fixture/first tag=alpha", "fixture/second tag=alpha"]
);
for spec in &config.rules {
assert_eq!(
spec.component
.as_ref()
.expect("a component-backed rule")
.options,
r#"{"tag":"alpha"}"#
);
}
}
#[test]
fn a_component_carries_the_options_it_was_configured_with() {
let fixture = Fixture::new("component-options", &[]);
fixture.write_component("rules/metadata.wasm", "metadata");
fixture.write_all(&[(
"lanekeep.json",
r#"{"namespaces": ["fixture"],
"rules": [{"rule": "./rules/metadata.wasm", "options": {"allow": ["a.rs"]}}]}"#,
)]);
let config = fixture.load_json().expect("loads");
let component = config.rules[0]
.component
.as_ref()
.expect("a component-backed rule");
assert_eq!(component.options, r#"{"allow":["a.rs"]}"#);
fixture.write_all(&[(
"lanekeep.json",
r#"{"namespaces": ["fixture"], "rules": ["./rules/metadata.wasm"]}"#,
)]);
let bare = fixture.load_json().expect("loads");
assert_eq!(
bare.rules[0]
.component
.as_ref()
.expect("a component-backed rule")
.options,
"null"
);
}
#[test]
fn a_component_that_refuses_its_options_is_refused_at_load() {
let fixture = Fixture::new("component-bad-options", &[]);
fixture.write_component("rules/metadata.wasm", "metadata");
fixture.write_all(&[(
"lanekeep.json",
r#"{"namespaces": ["fixture"],
"rules": [{"rule": "./rules/metadata.wasm", "options": [1, 2]}]}"#,
)]);
let error = fixture
.load_json()
.expect_err("the fixture refuses an array");
assert!(
matches!(error, ConfigError::Rule { position: 1, .. }),
"the diagnostic should name which entry: {error:?}"
);
assert!(
error.to_string().contains("expected an object"),
"the guest's own message should survive: {error}"
);
}
#[test]
fn a_component_that_is_not_there_is_refused_by_position() {
let fixture = Fixture::new(
"component-missing",
&[
("first.ts", &rule("local/first")),
(
"lanekeep.json",
r#"{"rules": ["./first", "./rules/gone.wasm"]}"#,
),
],
);
let error = fixture.load_json().expect_err("there are no bytes to run");
assert!(
matches!(error, ConfigError::Rule { position: 2, .. }),
"the diagnostic should name which entry: {error:?}"
);
assert!(error.to_string().contains("gone.wasm"), "{error}");
}
#[test]
fn the_ruleset_hash_covers_an_imported_helper() {
let files: &[(&str, &str)] = &[
("helper.ts", "export const QUERY = '(identifier) @id';\n"),
(
"rule.ts",
"import { defineRule } from 'lanekeep';\n\
import { QUERY } from './helper';\n\
export default defineRule({\n\
id: 'local/example',\n\
query: QUERY,\n\
card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
check() {},\n\
});\n",
),
("lanekeep.config.ts", ""),
];
let fixture = Fixture::new("helper-hash", files);
fixture.write_all(&[("lanekeep.config.ts", &config_with("rules: [rule]"))]);
let before = fixture.load_config().expect("loads").ruleset_hash;
fixture.write_all(&[("helper.ts", "export const QUERY = '(string) @s';\n")]);
let after = fixture.load_config().expect("loads").ruleset_hash;
assert_ne!(
hex(&before),
hex(&after),
"changing an imported helper must invalidate the ruleset hash"
);
}
#[test]
fn the_ruleset_hash_is_stable_when_nothing_changed() {
let fixture = Fixture::new(
"stable-hash",
&[
("rule.ts", &rule("local/example")),
("lanekeep.config.ts", &config_with("rules: [rule]")),
],
);
let first = fixture.load_config().expect("loads").ruleset_hash;
let second = fixture.load_config().expect("loads").ruleset_hash;
assert_eq!(hex(&first), hex(&second));
}
#[test]
fn the_ruleset_hash_covers_a_components_bytes() {
let fixture = Fixture::new("component-bytes", &[("mine.wasm", "\u{0}asm-one")]);
let sandbox = fixture.empty_sandbox();
let before = hash_ruleset(&sandbox, &[&fixture.component("mine.wasm")]);
fixture.write_all(&[("mine.wasm", "\u{0}asm-two")]);
let after = hash_ruleset(&sandbox, &[&fixture.component("mine.wasm")]);
assert_ne!(
hex(&before),
hex(&after),
"rebuilding a rule component must invalidate its cached results"
);
}
#[test]
fn two_rules_from_one_component_fold_its_bytes_once() {
let fixture = Fixture::new("component-two-rules", &[("a.wasm", "\u{0}asm-two-rules")]);
let sandbox = fixture.empty_sandbox();
let one = hash_ruleset(
&sandbox,
&[
&fixture.component_at("a.wasm", 0),
&fixture.component_at("a.wasm", 1),
],
);
let twice = hash_ruleset(
&sandbox,
&[
&fixture.component_at("a.wasm", 0),
&fixture.component_at("a.wasm", 0),
],
);
assert_ne!(
one, twice,
"distinct rule indices must not hash the same as the same index twice"
);
let listed_again = hash_ruleset(
&sandbox,
&[
&fixture.component_at("a.wasm", 0),
&fixture.component_at("a.wasm", 1),
&fixture.component_at("a.wasm", 0),
],
);
assert_eq!(
one, listed_again,
"a component's bytes must reach the fold once however many times it is listed"
);
}
#[test]
fn a_rule_is_folded_against_the_component_it_runs_in() {
let fixture = Fixture::new(
"component-rule-pairing",
&[("a.wasm", "\u{0}asm-a"), ("b.wasm", "\u{0}asm-b")],
);
let sandbox = fixture.empty_sandbox();
let dealt = hash_ruleset(
&sandbox,
&[
&fixture.component_at("a.wasm", 0),
&fixture.component_at("b.wasm", 1),
],
);
let swapped = hash_ruleset(
&sandbox,
&[
&fixture.component_at("a.wasm", 1),
&fixture.component_at("b.wasm", 0),
],
);
assert_ne!(
hex(&dealt),
hex(&swapped),
"which component a rule index belongs to is part of the ruleset"
);
}
#[test]
fn the_ruleset_hash_covers_the_options_a_component_was_configured_with() {
let fixture = Fixture::new("component-options-hash", &[("a.wasm", "\u{0}asm-a")]);
let sandbox = fixture.empty_sandbox();
let bare = fixture.component("a.wasm");
let mut configured = fixture.component("a.wasm");
configured.options = r#"{"limit":1}"#.to_owned();
assert_ne!(
hex(&hash_ruleset(&sandbox, &[&bare])),
hex(&hash_ruleset(&sandbox, &[&configured])),
"a component configured differently is a different ruleset"
);
}
#[test]
fn two_components_cannot_run_together_into_one() {
let fixture = Fixture::new("component-run-together", &[("a.wasm", ""), ("b.wasm", "")]);
let sandbox = fixture.empty_sandbox();
fixture.write_all(&[("a.wasm", "AA"), ("b.wasm", "BBCC")]);
let split_early = hash_ruleset(
&sandbox,
&[&fixture.component("a.wasm"), &fixture.component("b.wasm")],
);
fixture.write_all(&[("a.wasm", "AABB"), ("b.wasm", "CC")]);
let split_late = hash_ruleset(
&sandbox,
&[&fixture.component("a.wasm"), &fixture.component("b.wasm")],
);
assert_ne!(
hex(&split_early),
hex(&split_late),
"two components must not be able to concatenate into one byte sequence — the \
length is the only thing delimiting them, because any separator byte can appear \
inside a component"
);
}
#[test]
fn the_ruleset_hash_ignores_where_a_component_sits() {
let fixture = Fixture::new(
"component-path",
&[("a.wasm", "\u{0}asm-same"), ("nested/b.wasm", "")],
);
fixture.write_all(&[("nested/b.wasm", "\u{0}asm-same")]);
let sandbox = fixture.empty_sandbox();
assert_eq!(
hex(&hash_ruleset(&sandbox, &[&fixture.component("a.wasm")])),
hex(&hash_ruleset(
&sandbox,
&[&fixture.component("nested/b.wasm")]
)),
"the same component bytes are the same ruleset wherever they sit"
);
}
#[test]
fn the_ruleset_hash_ignores_the_order_and_the_repetition_of_a_component() {
let fixture = Fixture::new(
"component-order",
&[("one.wasm", "\u{0}asm-one"), ("two.wasm", "\u{0}asm-two")],
);
let sandbox = fixture.empty_sandbox();
let one = fixture.component("one.wasm");
let two = fixture.component("two.wasm");
let canonical = hex(&hash_ruleset(&sandbox, &[&one, &two]));
assert_eq!(
canonical,
hex(&hash_ruleset(&sandbox, &[&two, &one])),
"reordering two components is not a different ruleset"
);
assert_eq!(
canonical,
hex(&hash_ruleset(&sandbox, &[&one, &two, &one])),
"naming one component twice is not a different ruleset"
);
}
#[test]
fn one_path_with_two_byte_sequences_reaches_the_ruleset_hash_as_both() {
let fixture = Fixture::new("component-torn-read", &[("r.wasm", "\u{0}asm-before")]);
let sandbox = fixture.empty_sandbox();
let before = fixture.component("r.wasm");
fixture.write_all(&[("r.wasm", "\u{0}asm-after")]);
let after = fixture.component("r.wasm");
assert_eq!(
before.path, after.path,
"the fixture is one file, read twice"
);
assert_ne!(
before.bytes.as_slice(),
after.bytes.as_slice(),
"the rewrite is what makes this pair interesting"
);
fixture.write_all(&[("r.wasm", "\u{0}asm-again")]);
let again = fixture.component("r.wasm");
assert_eq!(after.path, again.path, "still one file, read a third time");
assert_ne!(
after.bytes.as_slice(),
again.bytes.as_slice(),
"the second rewrite is a third byte sequence, not a reread of the second"
);
assert_ne!(
hex(&hash_ruleset(&sandbox, &[&before, &after])),
hex(&hash_ruleset(&sandbox, &[&before, &again])),
"two rulesets whose rules fold agrees but whose second component's bytes differ \
must not key equal — a component fold that hashed the count of distinct programs \
but not the bytes made these equal"
);
}
#[test]
fn the_ruleset_hash_still_covers_modules_when_a_component_is_present() {
let files: &[(&str, &str)] = &[
("rule.ts", &rule("local/example")),
("lanekeep.config.ts", ""),
];
let fixture = Fixture::new("component-and-module", files);
fixture.write_all(&[("lanekeep.config.ts", &config_with("rules: [rule]"))]);
fixture.write_all(&[("mine.wasm", "\u{0}asm")]);
let mine = fixture.component("mine.wasm");
let root = RuleRoot::new(&fixture.dir).expect("canonicalizes");
let hash_after_loading = |source: &str| {
fixture.write_all(&[("rule.ts", source)]);
let sandbox =
sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox");
evaluate_into(&sandbox, &root, &fixture.dir.join("lanekeep.config.ts"))
.expect("evaluates");
hash_ruleset(&sandbox, &[&mine])
};
assert_ne!(
hex(&hash_after_loading(&rule("local/example"))),
hex(&hash_after_loading(&rule("local/renamed"))),
"a module edit must still invalidate when a component is in the ruleset too"
);
}
#[test]
fn the_config_hash_ignores_glob_order() {
let make = |globs: &str, tag: &str| {
Fixture::new(
&format!("glob-order-{tag}"),
&[
("rule.ts", &rule("local/example")),
(
"lanekeep.config.ts",
&config_with(&format!("rules: [rule], include: {globs}")),
),
],
)
.load_config()
.expect("loads")
.config_hash
};
assert_eq!(
hex(&make("['a/**', 'b/**']", "sorted")),
hex(&make("['b/**', 'a/**' ]", "reversed")),
"reordering globs must not change the config hash"
);
}
#[test]
fn the_config_hash_changes_with_severity() {
let make = |extra: &str, tag: &str| {
Fixture::new(
&format!("severity-hash-{tag}"),
&[
("rule.ts", &rule("local/example")),
(
"lanekeep.config.ts",
&config_with(&format!("rules: [rule]{extra}")),
),
],
)
.load_config()
.expect("loads")
.config_hash
};
assert_ne!(
hex(&make("", "none")),
hex(&make(", severity: { 'local/example': 'warn' }", "warn")),
"changing a severity must invalidate"
);
}
#[test]
fn the_config_hash_changes_with_a_timeout() {
let make = |extra: &str, tag: &str| {
Fixture::new(
&format!("timeout-hash-{tag}"),
&[
("rule.ts", &rule("local/example")),
(
"lanekeep.config.ts",
&config_with(&format!("rules: [rule]{extra}")),
),
],
)
.load_config()
.expect("loads")
.config_hash
};
assert_ne!(
hex(&make("", "d")),
hex(&make(", timeouts: { rule: 5000 }", "t"))
);
}
#[test]
fn the_config_hash_changes_with_a_json_rule_option() {
let config =
|options: &str| format!(r#"{{"rules": [{{"rule": "./rule", "options": {options}}}]}}"#);
let fixture = Fixture::new(
"json-option-hash",
&[
("rule.ts", &factory_rule("local/example")),
("lanekeep.json", &config(r#"{"limit": 1}"#)),
],
);
let before = fixture.load_json().expect("loads");
fixture.write_all(&[("lanekeep.json", &config(r#"{"limit": 2}"#))]);
let after = fixture.load_json().expect("loads");
assert_ne!(
hex(&before.config_hash),
hex(&after.config_hash),
"editing a rule option must invalidate"
);
assert_eq!(
hex(&before.ruleset_hash),
hex(&after.ruleset_hash),
"no module changed, so the ruleset hash must not move — which is exactly why \
the config hash has to"
);
}
#[test]
fn the_ruleset_hash_covers_an_imported_helper_for_json() {
let fixture = Fixture::new(
"json-helper-hash",
&[
("helper.ts", "export const QUERY = '(identifier) @id';\n"),
(
"rule.ts",
"import { defineRule } from 'lanekeep';\n\
import { QUERY } from './helper';\n\
export default defineRule({\n\
id: 'local/example',\n\
query: QUERY,\n\
card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
check() {},\n\
});\n",
),
("lanekeep.json", r#"{"rules": ["./rule"]}"#),
],
);
let before = fixture.load_json().expect("loads").ruleset_hash;
fixture.write_all(&[("helper.ts", "export const QUERY = '(string) @s';\n")]);
let after = fixture.load_json().expect("loads").ruleset_hash;
assert_ne!(
hex(&before),
hex(&after),
"changing an imported helper must invalidate the ruleset hash"
);
}
#[test]
fn the_ruleset_hash_is_stable_when_nothing_changed_for_json() {
let fixture = Fixture::new(
"json-stable-hash",
&[
("rule.ts", &rule("local/example")),
("lanekeep.json", r#"{"rules": ["./rule"]}"#),
],
);
let first = fixture.load_json().expect("loads").ruleset_hash;
let second = fixture.load_json().expect("loads").ruleset_hash;
assert_eq!(hex(&first), hex(&second));
}
#[test]
fn the_config_hash_ignores_glob_order_for_json() {
let make = |globs: &str, tag: &str| {
Fixture::new(
&format!("json-glob-order-{tag}"),
&[
("rule.ts", &rule("local/example")),
(
"lanekeep.json",
&format!(r#"{{"rules": ["./rule"], "include": {globs}}}"#),
),
],
)
.load_json()
.expect("loads")
.config_hash
};
assert_eq!(
hex(&make(r#"["a/**", "b/**"]"#, "sorted")),
hex(&make(r#"["b/**", "a/**"]"#, "reversed")),
"reordering globs must not change the config hash"
);
}
#[test]
fn the_config_hash_ignores_option_key_order() {
let make = |options: &str, tag: &str| {
Fixture::new(
&format!("json-option-order-{tag}"),
&[
("rule.ts", &factory_rule("local/example")),
(
"lanekeep.json",
&format!(r#"{{"rules": [{{"rule": "./rule", "options": {options}}}]}}"#),
),
],
)
.load_json()
.expect("loads")
.config_hash
};
assert_eq!(
hex(&make(r#"{"a": 1, "b": 2}"#, "sorted")),
hex(&make(r#"{"b": 2, "a": 1}"#, "reversed")),
"reordering option keys must not change the config hash"
);
}
#[test]
fn the_config_hash_changes_with_severity_for_json() {
let make = |severity: &str, tag: &str| {
Fixture::new(
&format!("json-severity-hash-{tag}"),
&[
("rule.ts", &rule("local/example")),
(
"lanekeep.json",
&format!(r#"{{"rules": ["./rule"], "severity": {severity}}}"#),
),
],
)
.load_json()
.expect("loads")
.config_hash
};
assert_ne!(
hex(&make("{}", "none")),
hex(&make(r#"{"local/example": "warn"}"#, "warn")),
"changing a severity must invalidate"
);
}
#[test]
fn the_config_hash_changes_with_a_timeout_for_json() {
let make = |timeouts: &str, tag: &str| {
Fixture::new(
&format!("json-timeout-hash-{tag}"),
&[
("rule.ts", &rule("local/example")),
(
"lanekeep.json",
&format!(r#"{{"rules": ["./rule"], "timeouts": {timeouts}}}"#),
),
],
)
.load_json()
.expect("loads")
.config_hash
};
assert_ne!(hex(&make("{}", "d")), hex(&make(r#"{"rule": 5000}"#, "t")));
}
#[test]
fn the_config_hash_tells_a_bare_rule_from_a_configured_one() {
let module = "import { defineRule } from 'lanekeep';\n\
const built = defineRule({\n\
id: 'local/example',\n\
query: '(identifier) @id',\n\
card: { message: 'no', remediation: 'do this', examples: { bad: 'a', good: 'b' } },\n\
check(ctx, m) { ctx.report(m.id); },\n\
});\n\
export default Object.assign((options) => built, built);\n";
let make = |rules: &str, tag: &str| {
Fixture::new(
&format!("json-rule-form-{tag}"),
&[
("rule.ts", module),
("lanekeep.json", &format!(r#"{{"rules": [{rules}]}}"#)),
],
)
.load_json()
.expect("loads")
.config_hash
};
assert_ne!(
hex(&make(r#""./rule""#, "bare")),
hex(&make(r#"{"rule": "./rule"}"#, "configured")),
"a rule used as it comes and a rule configured with `null` are not the same run"
);
}
#[test]
fn the_config_hash_tells_apart_two_rules_with_the_same_options() {
let make = |name: &str| {
Fixture::new(
&format!("json-which-rule-{name}"),
&[
(
&format!("{name}.ts"),
&factory_rule(&format!("local/{name}")),
),
(
"lanekeep.json",
&format!(r#"{{"rules": [{{"rule": "./{name}", "options": {{"x": 1}}}}]}}"#),
),
],
)
.load_json()
.expect("loads")
.config_hash
};
assert_ne!(
hex(&make("a")),
hex(&make("b")),
"the same options on a different rule is a different configuration"
);
}
#[test]
fn the_two_formats_load_the_same_configuration() {
let typescript = Fixture::new(
"parity-ts",
&[
("rule.ts", &rule("local/example")),
(
"lanekeep.config.ts",
&config_with(
"rules: [rule], include: ['src/**/*.ts'], exclude: ['**/*.test.ts'], \
severity: { 'local/example': 'warn' }, \
timeouts: { rule: 2000, global: 30000 }",
),
),
],
)
.load_config()
.expect("the TypeScript config loads");
let json = Fixture::new(
"parity-json",
&[
("rule.ts", &rule("local/example")),
(
"lanekeep.json",
r#"{"rules": ["./rule"], "include": ["src/**/*.ts"],
"exclude": ["**/*.test.ts"], "severity": {"local/example": "warn"},
"timeouts": {"rule": 2000, "global": 30000}}"#,
),
],
)
.load_json()
.expect("the JSON config loads");
assert_eq!(typescript.include, json.include);
assert_eq!(typescript.exclude, json.exclude);
assert_eq!(typescript.limits, json.limits);
assert_eq!(typescript.rules, json.rules);
}
#[test]
fn the_json_path_names_nothing_from_the_sandbox_crate() {
let root = include_str!("lib.rs");
let import = root
.lines()
.find(|line| line.starts_with("use lanekeep_js::{"))
.expect("the crate root imports the sandbox crate in one braced list");
let mut forbidden: Vec<&str> = import
.trim_start_matches("use lanekeep_js::{")
.trim_end_matches("};")
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.collect();
assert!(
forbidden.len() > 1
&& forbidden
.iter()
.all(|n| n.chars().all(char::is_alphanumeric)),
"the import list should have parsed into type names: {forbidden:?}"
);
forbidden.push("lanekeep_js");
let source = include_str!("json.rs");
for name in forbidden {
assert!(
!source.contains(name),
"src/json.rs must resolve a JSON config without the sandbox, and it names \
`{name}`"
);
}
}
#[test]
fn hex_renders_a_full_hash() {
assert_eq!(hex(&[0u8; 32]).len(), 64);
assert_eq!(hex(&[0xab; 32]), "ab".repeat(32));
}
}