use std::fs;
use std::path::{Path, PathBuf};
pub fn npm_package_name(source: &str) -> Option<String> {
if source.starts_with('.') || source.starts_with('/') {
return None;
}
let mut segments = source.split('/');
let first = segments.next()?;
if first.is_empty() {
return None;
}
if let Some(stripped) = first.strip_prefix('@') {
let second = segments.next()?;
if stripped.is_empty() || second.is_empty() {
return None;
}
Some(format!("{first}/{second}"))
} else {
Some(first.to_string())
}
}
pub fn plugin_directory_name(package: &str) -> String {
package.replace('@', "").replace('/', "__")
}
fn find_upward(start: &Path, relative: &str) -> Option<PathBuf> {
let mut current = Some(start);
while let Some(dir) = current {
let candidate = dir.join(relative);
if candidate.exists() {
return Some(candidate);
}
current = dir.parent();
}
None
}
pub fn locked_package(project_root: &Path, package: &str) -> Option<(String, String)> {
let lockfile = find_upward(project_root, "pnpm-lock.yaml")?;
let text = fs::read_to_string(&lockfile).ok()?;
locked_package_from_text(&text, package)
}
fn locked_package_from_text(text: &str, package: &str) -> Option<(String, String)> {
let prefix = format!("{package}@");
let mut lines = text.lines();
while let Some(line) = lines.next() {
let Some(key) = line.strip_prefix(" ") else {
continue;
};
let Some(key) = key.strip_suffix(':') else {
continue;
};
let key = key
.strip_prefix('\'')
.and_then(|value| value.strip_suffix('\''))
.or_else(|| {
key.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
})
.unwrap_or(key);
let Some(version) = key.strip_prefix(&prefix) else {
continue;
};
let version = version.split('(').next().unwrap_or(version).to_string();
for follow in lines.by_ref() {
let follow = follow.trim();
if let Some(resolution) = follow.strip_prefix("resolution: {integrity: ") {
let integrity = resolution
.trim_end_matches('}')
.trim_end_matches(',')
.trim()
.to_string();
return Some((version, integrity));
}
if follow.is_empty() {
break;
}
}
return None;
}
None
}
pub fn vetting_record(project_root: &Path, package: &str) -> Option<(String, String, PathBuf)> {
let relative = format!("plugins/{}/VETTING.md", plugin_directory_name(package));
let path = find_upward(project_root, &relative)?;
let text = fs::read_to_string(&path).ok()?;
let mut version = None;
let mut integrity = None;
for line in text.lines() {
let line = line.trim();
if let Some(value) = line.strip_prefix("version:") {
version = Some(value.trim().to_string());
} else if let Some(value) = line.strip_prefix("integrity:") {
integrity = Some(value.trim().to_string());
}
}
Some((version?, integrity?, path))
}
pub fn validate_npm_imports(
project_root: &Path,
sources: impl IntoIterator<Item = String>,
strict: bool,
) -> Result<Vec<String>, String> {
let mut packages = sources
.into_iter()
.filter_map(|source| npm_package_name(&source))
.collect::<Vec<_>>();
packages.sort();
packages.dedup();
let mut warnings = Vec::new();
for package in packages {
let locked = locked_package(project_root, &package);
let record = vetting_record(project_root, &package);
let problem = match (&locked, &record) {
(None, _) => Some(format!(
"`{package}` is imported but not present in the committed lockfile"
)),
(Some(_), None) => Some(format!(
"`{package}` has no vetting record; run `noxid vet {package}` and review plugins/{}/VETTING.md",
plugin_directory_name(&package)
)),
(Some((version, integrity)), Some((recorded_version, recorded_integrity, path))) => {
if version != recorded_version {
Some(format!(
"`{package}` is locked at {version} but {} covers {recorded_version}; re-vet with `noxid vet {package}`",
path.display()
))
} else if integrity != recorded_integrity {
Some(format!(
"`{package}` integrity {integrity} does not match the vetting record in {}; the package content changed — re-vet with `noxid vet {package}`",
path.display()
))
} else {
None
}
}
};
if let Some(problem) = problem {
if strict {
return Err(format!("error[NPM_IMPORT_UNVETTED]: {problem}"));
}
warnings.push(format!("warning[NPM_IMPORT_UNVETTED]: {problem}"));
}
}
Ok(warnings)
}
fn vendored_plugin_files_present(project_root: &Path) -> Vec<&'static str> {
crate::scaffold::embedded_plugins()
.files
.iter()
.map(|(relative, _)| *relative)
.filter(|relative| project_root.join(relative).is_file())
.collect()
}
fn refuse_vendored_plugins_without_a_ledger(
project_root: &Path,
ledger_path: &Path,
) -> Result<(), String> {
let vendored = vendored_plugin_files_present(project_root);
if vendored.is_empty() {
return Ok(());
}
Err(format!(
"error[PLUGIN_LEDGER_MISSING]: {} vendors compiler-owned plugin file(s) ({}) but has no {}. That ledger is the only record of which vetted copy those files are, and `plugins/drizzle-orm/adapter.js` is this project's data boundary — without the ledger a modified copy would build unnoticed. Restore the ledger from version control, or re-vendor the files from this compiler with `noxid vet --sync` inside the project.",
project_root.display(),
vendored.join(", "),
ledger_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(crate::scaffold::PLUGIN_LEDGER),
))
}
fn ledger_field(entry: &str, name: &str) -> Option<String> {
let key = format!("\"{name}\": \"");
let start = entry.find(&key)? + key.len();
let end = entry[start..].find('"')? + start;
Some(entry[start..end].to_string())
}
fn ledger_entries(ledger: &str, name: &str) -> Vec<String> {
let key = format!("\"{name}\": [");
let Some(start) = ledger.find(&key).map(|index| index + key.len()) else {
return Vec::new();
};
let Some(end) = ledger[start..].find(']').map(|index| index + start) else {
return Vec::new();
};
let mut entries = Vec::new();
let mut rest = &ledger[start..end];
while let Some(open) = rest.find('{') {
let Some(close) = rest[open..].find('}').map(|index| index + open) else {
break;
};
entries.push(rest[open..=close].to_string());
rest = &rest[close + 1..];
}
entries
}
pub fn validate_vendored_plugins(project_root: &Path) -> Result<(), String> {
let ledger_path = project_root.join(crate::scaffold::PLUGIN_LEDGER);
let Ok(ledger) = fs::read_to_string(&ledger_path) else {
return refuse_vendored_plugins_without_a_ledger(project_root, &ledger_path);
};
let files = ledger_entries(&ledger, "files");
if files.is_empty() {
return Err(format!(
"error[PLUGIN_VENDOR_DRIFT]: {} lists no vendored files; restore it from `noxid new`, \
or delete it if this project no longer vendors compiler-owned plugins",
ledger_path.display()
));
}
for entry in files {
let (Some(relative), Some(expected)) =
(ledger_field(&entry, "path"), ledger_field(&entry, "sha256"))
else {
return Err(format!(
"error[PLUGIN_VENDOR_DRIFT]: {} has an entry without both `path` and `sha256`; \
the ledger is compiler-written — scaffold a fresh project with `noxid new` and \
copy its ledger back",
ledger_path.display()
));
};
let path = project_root.join(&relative);
let contents = fs::read(&path).map_err(|error| {
format!(
"error[PLUGIN_VENDOR_DRIFT]: {} is named by {} but cannot be read ({error}); the \
vetted plugin files travel with the project and are part of its build",
path.display(),
ledger_path.display()
)
})?;
let actual = crate::sha256::hex_digest(&contents);
if actual != expected {
return Err(format!(
"error[PLUGIN_VENDOR_DRIFT]: {} no longer matches the vetted copy recorded in {} \
(expected sha256 {expected}, found {actual}); the adapter is the compiler-owned \
data boundary, not an editable project file. Restore the original, or vet a \
replacement and record it under plugins/ before building.",
path.display(),
ledger_path.display()
));
}
}
for entry in ledger_entries(&ledger, "pins") {
let (Some(package), Some(version), Some(integrity)) = (
ledger_field(&entry, "package"),
ledger_field(&entry, "version"),
ledger_field(&entry, "integrity"),
) else {
return Err(format!(
"error[NPM_IMPORT_UNVETTED]: a pin in {} is missing `package`, `version`, or \
`integrity`; every vendored plugin names the exact release it was reviewed \
against",
ledger_path.display()
));
};
let Some((locked_version, locked_integrity)) = locked_package(project_root, &package)
else {
return Err(format!(
"error[NPM_IMPORT_UNVETTED]: `{package}` is pinned at {version} by {} but the \
committed lockfile does not resolve it; run `pnpm install --frozen-lockfile` \
before building, and keep the pinned version in package.json",
ledger_path.display()
));
};
if locked_version != version {
return Err(format!(
"error[NPM_IMPORT_UNVETTED]: `{package}` is locked at {locked_version} but the \
vendored vetting record covers {version}; the adapter is only reviewed against \
the pinned release. Restore `\"{package}\": \"{version}\"` in package.json and \
re-run `pnpm install --frozen-lockfile`, or re-vet with `noxid vet {package}`."
));
}
if locked_integrity != integrity {
return Err(format!(
"error[NPM_IMPORT_UNVETTED]: `{package}@{version}` resolves to integrity \
{locked_integrity}, not the reviewed {integrity}; the published content changed \
under the same version. Re-vet with `noxid vet {package}` before building."
));
}
}
Ok(())
}
pub fn run_vet_command(args: impl Iterator<Item = String>) -> Result<(), String> {
let mut sync = false;
let mut package: Option<String> = None;
for argument in args {
match argument.as_str() {
"--sync" => sync = true,
other if other.starts_with("--") => {
return Err(format!(
"unknown vet argument `{other}`; write `noxid vet <package[@version]>` to vet \
an npm package, or `noxid vet [--sync]` inside a scaffolded project to check \
or refresh its vendored plugins"
));
}
other if package.is_some() => {
return Err(format!(
"noxid vet accepts one npm package specifier; received a second, `{other}`"
));
}
other => package = Some(other.to_string()),
}
}
match (package, sync) {
(Some(_), true) => Err(
"`noxid vet --sync` refreshes a scaffolded project's vendored \
plugins from this compiler and takes no package name; run it \
from the project root, or drop `--sync` to vet a package"
.into(),
),
(Some(package), false) => run_vet(&package),
(None, sync) => {
let root = std::env::current_dir()
.map_err(|error| format!("cannot resolve the current directory: {error}"))?;
sync_vendored_plugins(&root, sync)
}
}
}
enum FileState {
Current,
Drifted { project: String, embedded: String },
Absent,
}
pub fn sync_vendored_plugins(project_root: &Path, sync: bool) -> Result<(), String> {
let ledger_path = project_root.join(crate::scaffold::PLUGIN_LEDGER);
let project_ledger = match fs::read_to_string(&ledger_path) {
Ok(ledger) => ledger,
Err(_) if !vendored_plugin_files_present(project_root).is_empty() => String::new(),
Err(error) => {
return Err(format!(
"error[PLUGIN_LEDGER_MISSING]: {} has no {} ({error}); `noxid vet` with no package \
checks the vetted plugin files a scaffolded project carries, and this directory \
vendors none. Run it from a project created by `noxid new --template app`, or write \
`noxid vet <package>` to vet an npm package.",
project_root.display(),
crate::scaffold::PLUGIN_LEDGER
));
}
};
let embedded = crate::scaffold::embedded_plugins();
let embedded_ledger = crate::scaffold::plugin_ledger(&embedded)?;
let mut states = Vec::new();
for (relative, contents) in &embedded.files {
let path = project_root.join(relative);
let state = match fs::read(&path) {
Err(_) => FileState::Absent,
Ok(found) if found == contents.as_bytes() => FileState::Current,
Ok(found) => FileState::Drifted {
project: crate::sha256::hex_digest(&found),
embedded: crate::sha256::hex_digest(contents.as_bytes()),
},
};
states.push((*relative, state));
}
let ledger_current = project_ledger == embedded_ledger;
let mut retired = Vec::new();
for entry in ledger_entries(&project_ledger, "files") {
let Some(relative) = ledger_field(&entry, "path") else {
continue;
};
if !embedded
.files
.iter()
.any(|(path, _)| *path == relative.as_str())
{
retired.push(relative);
}
}
let width = embedded
.files
.iter()
.map(|(path, _)| path.len())
.chain(std::iter::once(crate::scaffold::PLUGIN_LEDGER.len()))
.max()
.unwrap_or(0);
let mut stale = 0usize;
for (relative, state) in &states {
match state {
FileState::Current => println!(" current {relative}"),
FileState::Drifted { project, embedded } => {
stale += 1;
println!(
" drifted {relative:<width$} project {}, this compiler {}",
&project[..12],
&embedded[..12]
);
}
FileState::Absent => {
stale += 1;
println!(" missing {relative:<width$} vendored by this compiler, not on disk");
}
}
}
if ledger_current {
println!(" current {}", crate::scaffold::PLUGIN_LEDGER);
} else {
stale += 1;
println!(
" drifted {:<width$} source commit {}",
crate::scaffold::PLUGIN_LEDGER,
short_commit(&embedded.commit)
);
}
for relative in &retired {
println!(
" retired {relative:<width$} this compiler no longer vendors it; left in place"
);
}
if stale == 0 {
println!(
"{} vendored plugin file(s) and {} already match this compiler ({}); nothing to sync",
embedded.files.len(),
crate::scaffold::PLUGIN_LEDGER,
short_commit(&embedded.commit)
);
return Ok(());
}
if !sync {
return Err(format!(
"error[PLUGIN_VENDOR_DRIFT]: {stale} of {} vendored plugin record(s) in {} differ from \
the vetted copies this compiler embeds ({}). Run `noxid vet --sync` to rewrite them \
from this compiler, or restore the originals; `noxid vet` never updates a project on \
its own.",
embedded.files.len() + 1,
project_root.display(),
short_commit(&embedded.commit)
));
}
for (relative, state) in &states {
if matches!(state, FileState::Current) {
continue;
}
let path = project_root.join(relative);
let contents = embedded
.files
.iter()
.find(|(candidate, _)| candidate == relative)
.map(|(_, contents)| contents)
.expect("state built from the embedded set");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
}
fs::write(&path, contents.as_bytes())
.map_err(|error| format!("cannot write {}: {error}", path.display()))?;
println!("synced {relative}");
}
if !ledger_current {
fs::write(&ledger_path, &embedded_ledger)
.map_err(|error| format!("cannot write {}: {error}", ledger_path.display()))?;
println!("synced {}", crate::scaffold::PLUGIN_LEDGER);
}
println!(
"vendored plugins now match this compiler ({})",
short_commit(&embedded.commit)
);
for entry in ledger_entries(&embedded_ledger, "pins") {
let (Some(package), Some(version)) = (
ledger_field(&entry, "package"),
ledger_field(&entry, "version"),
) else {
continue;
};
match locked_package(project_root, &package) {
Some((locked, _)) if locked == version => {}
_ => println!(
"note: `{package}` is now vetted at {version}; set that version in package.json \
and re-run `pnpm install --frozen-lockfile` before building"
),
}
}
Ok(())
}
fn short_commit(commit: &str) -> &str {
if commit.len() >= 12 && commit.chars().all(|c| c.is_ascii_hexdigit()) {
&commit[..12]
} else {
commit
}
}
pub fn run_vet(package: &str) -> Result<(), String> {
let repo_root = std::env::current_dir().map_err(|error| error.to_string())?;
let script = find_upward(&repo_root, "tools/npm-vet.mjs")
.ok_or("cannot find tools/npm-vet.mjs above the current directory")?;
let output = std::process::Command::new("node")
.arg(&script)
.arg(package)
.output()
.map_err(|error| format!("cannot run the vetting tool: {error}"))?;
let report = String::from_utf8_lossy(&output.stdout).to_string();
if report.trim().is_empty() {
return Err(format!(
"the vetting tool produced no report:\n{}",
String::from_utf8_lossy(&output.stderr)
));
}
let field = |name: &str| -> Option<String> {
let key = format!("\"{name}\": \"");
let start = report.find(&key)? + key.len();
let end = report[start..].find('"')? + start;
Some(report[start..end].to_string())
};
let identity = field("package").ok_or("vet report is missing the package field")?;
let (name, version) = identity
.rsplit_once('@')
.ok_or("vet report package field is not name@version")?;
let integrity = field("integrity").ok_or("vet report is missing the integrity field")?;
let verdict = field("verdict").unwrap_or_else(|| "REVIEW".into());
let plugin_root = script
.parent()
.and_then(Path::parent)
.ok_or("cannot resolve repository root")?
.join("plugins")
.join(plugin_directory_name(name));
fs::create_dir_all(&plugin_root).map_err(|error| error.to_string())?;
let record_path = plugin_root.join("VETTING.md");
let record = format!(
"# Vetting record: {name}\n\npackage: {name}\nversion: {version}\nintegrity: {integrity}\nverdict: {verdict}\nreviewed-by: (fill in)\ndate: (fill in)\n\n## Justifications\n\n(review every finding in the report below; justify or reject each\nhigh/medium finding in writing — see docs/npm-admission.md)\n\n## Stage-1 report\n\n```json\n{report}```\n",
);
fs::write(&record_path, record).map_err(|error| error.to_string())?;
println!(
"vetted {name}@{version} ({verdict}) -> {}",
record_path.display()
);
if verdict == "REJECT-OR-JUSTIFY" {
println!("high findings present: justify them in the record before admitting");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn npm_specifiers_resolve_to_package_roots() {
assert_eq!(
npm_package_name("es-toolkit/array").as_deref(),
Some("es-toolkit")
);
assert_eq!(
npm_package_name("@formkit/tempo/dist").as_deref(),
Some("@formkit/tempo")
);
assert_eq!(npm_package_name("./search.js"), None);
assert_eq!(npm_package_name("../lib/helpers.js"), None);
assert_eq!(plugin_directory_name("@formkit/tempo"), "formkit__tempo");
}
#[test]
fn lock_lookup_accepts_pnpm_quoted_scoped_package_keys() {
let lock = "packages:\n\n '@formkit/auto-animate@0.10.0':\n resolution: {integrity: sha512-test}\n\n plain@1.2.3:\n resolution: {integrity: sha512-plain}\n";
assert_eq!(
locked_package_from_text(lock, "@formkit/auto-animate"),
Some(("0.10.0".into(), "sha512-test".into()))
);
assert_eq!(
locked_package_from_text(lock, "plain"),
Some(("1.2.3".into(), "sha512-plain".into()))
);
}
}