use super::checkpipe::Diagnostic;
use super::design::{Design, HttpMethod, ModuleDesign};
use super::mounting;
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
fn d(
code: &str,
file: Option<String>,
line: Option<u64>,
message: String,
suggestion: &str,
doc: &str,
) -> Diagnostic {
Diagnostic {
code: code.into(),
file,
line,
message,
suggestion: Some(suggestion.into()),
doc_url: Some(doc.into()),
}
}
pub fn run(root: &Path, design: &Design) -> Vec<Diagnostic> {
let mut out = Vec::new();
for m in &design.modules {
lint_public_surface(root, m, &mut out);
lint_handlers(root, m, &format!("crates/routes/{}/src", m.name), &mut out);
}
lint_generated_drift(root, design, &mut out);
lint_unguarded_mutations(design, &mut out);
lint_unscoped_tenant_queries(root, design, &mut out);
lint_boundary_escapes(root, design, &mut out);
out
}
fn lint_boundary_escapes(root: &Path, design: &Design, out: &mut Vec<Diagnostic>) {
const NEEDLES: [&str; 6] = [
"std::process::",
"std::fs::",
"std::net::",
"tokio::process::",
"tokio::fs::",
"tokio::net::",
];
const ALLOW: &str = "// jerrycan:allow JL0007";
const FILES: [&str; 4] = ["handlers.rs", "repo.rs", "deps.rs", "model.rs"];
let mut rels: Vec<String> = Vec::new();
fn collect(src_rel: &str, m: &ModuleDesign, files: &[&str], rels: &mut Vec<String>) {
for f in files {
rels.push(format!("{src_rel}/{f}"));
}
for sub in &m.subroutes {
collect(
&format!("{src_rel}/subroutes/{}", sub.name.replace('-', "_")),
sub,
files,
rels,
);
}
}
for m in &design.modules {
collect(
&format!("crates/routes/{}/src", m.name),
m,
&FILES,
&mut rels,
);
}
for rel in rels {
let Ok(content) = std::fs::read_to_string(root.join(&rel)) else {
continue; };
for (i, line) in content.lines().enumerate() {
if line.trim_start().starts_with("//") {
continue;
}
if !NEEDLES.iter().any(|n| line.contains(n)) {
continue;
}
if line.trim_end().ends_with(ALLOW) {
continue;
}
out.push(d(
"JL0007",
Some(rel.clone()),
Some(i as u64 + 1),
"handler code reaches outside the request boundary (process/fs/net)".into(),
"use framework extensions for I/O; if this is genuinely intended, append `// jerrycan:allow JL0007` to the line",
"jerrycan docs errors",
));
}
}
}
fn lint_unscoped_tenant_queries(root: &Path, design: &Design, out: &mut Vec<Diagnostic>) {
let mut tenant_modules: BTreeMap<&str, bool> = BTreeMap::new();
for (module, entity) in design.tenant_owned() {
let is_flat = design
.modules
.iter()
.find(|m| m.name == module)
.and_then(|m| m.entities.iter().find(|e| e.name == entity))
.is_some_and(|e| super::genroute::entity_is_flat_tenant_owned(e, design));
let flat = tenant_modules.entry(module).or_insert(false);
*flat = *flat || is_flat;
}
for (&module, &is_flat) in &tenant_modules {
scan_unscoped(
root,
module,
"a tenant-owned",
"another tenant's rows",
"call a scoped accessor instead — path-scoped routes: all_for/get_for/remove_for with the tenant id; flat (membership-set) routes: all_for_memberships/get_for_memberships/update_for_memberships/remove_for_memberships (and create_for_memberships) with the session user's id (_user.0.id)",
is_flat,
out,
);
}
for module in identity_owned_modules(design) {
if tenant_modules.contains_key(module) {
continue;
}
scan_unscoped(
root,
module,
"an identity-owned",
"another user's rows",
"call the owner-scoped accessor (all_for/get_for/remove_for) with the session user's id (_user.0.id)",
false,
out,
);
}
}
fn scan_unscoped(
root: &Path,
module: &str,
owned_desc: &str,
leak_desc: &str,
suggestion: &str,
flag_insert: bool,
out: &mut Vec<Diagnostic>,
) {
const BASE: [&str; 4] = ["repo.all()", "repo.get(", "repo.remove(", "repo.update("];
const ALLOW: &str = "// jerrycan:allow JL0006";
let rel = format!("crates/routes/{module}/src/handlers.rs");
let Ok(content) = std::fs::read_to_string(root.join(&rel)) else {
return;
};
for (i, line) in content.lines().enumerate() {
let hit = BASE
.iter()
.copied()
.find(|&p| line.contains(p))
.or_else(|| (flag_insert && line.contains("repo.insert(")).then_some("repo.insert("));
let Some(hit) = hit else {
continue;
};
if line.trim_end().ends_with(ALLOW) {
continue;
}
out.push(d(
"JL0006",
Some(rel.clone()),
Some(i as u64 + 1),
format!(
"handler in module `{module}` calls the unscoped `{hit}` on {owned_desc} repo — it can read, write, or delete {leak_desc}"
),
suggestion,
"jerrycan docs database",
));
}
}
fn identity_owned_modules(design: &Design) -> BTreeSet<&str> {
let mut out = BTreeSet::new();
if !design.wants_auth() {
return out;
}
for m in &design.modules {
let has_per_user = m.entities.iter().any(|e| {
e.belongs_to.iter().any(Design::is_identity_fk)
&& !design
.tenancy
.as_ref()
.is_some_and(|t| e.belongs_to.iter().any(|b| b.entity == t.entity))
});
if has_per_user {
out.insert(m.name.as_str());
}
}
out
}
fn lint_unguarded_mutations(design: &Design, out: &mut Vec<Diagnostic>) {
if !design.wants_auth() {
return;
}
fn walk(m: &ModuleDesign, out: &mut Vec<Diagnostic>) {
for ep in &m.endpoints {
let mutating = matches!(
ep.method,
HttpMethod::POST | HttpMethod::PUT | HttpMethod::PATCH | HttpMethod::DELETE
);
if mutating && !ep.is_guarded() && !ep.public && !ep.declares_signature_auth() {
out.push(d(
"JL0004",
Some("design.json".into()),
None,
format!(
"mutating route `{}` in module `{}` has no auth guard (design declares auth)",
ep.operation_id, m.name
),
"set auth_required: true or required_roles in design.json",
"jerrycan docs auth",
));
}
}
for sub in &m.subroutes {
walk(sub, out);
}
}
for m in &design.modules {
walk(m, out);
}
}
fn lint_public_surface(root: &Path, m: &ModuleDesign, out: &mut Vec<Diagnostic>) {
let rel = format!("crates/routes/{}/src/lib.rs", m.name);
let Ok(content) = std::fs::read_to_string(root.join(&rel)) else {
return;
};
for (i, line) in content.lines().enumerate() {
let t = line.trim_start();
if !t.starts_with("pub ") || t.starts_with("pub(") {
continue;
}
if t.starts_with("pub fn module(") {
continue;
}
out.push(d(
"JL0001",
Some(rel.clone()),
Some(i as u64 + 1),
format!(
"route crate `{}` exports more than `module()`: `{}`",
m.name,
t.trim_end()
),
"make it pub(crate), move shared types to the shared crate, or expose via module(); to reach another module's TABLE, declare a narrow second entity in your own module (jerrycan docs database)",
"jerrycan docs modules#anti-patterns",
));
}
}
fn lint_handlers(root: &Path, m: &ModuleDesign, src_rel: &str, out: &mut Vec<Diagnostic>) {
let rel = format!("{src_rel}/handlers.rs");
let content = std::fs::read_to_string(root.join(&rel)).unwrap_or_default();
for ep in &m.endpoints {
if !content.contains(&format!("async fn {}(", ep.operation_id)) {
out.push(d(
"JL0002",
Some(rel.clone()),
None,
format!(
"handler `{}` (from design.json) is missing in {rel}",
ep.operation_id
),
"add the handler with that exact name, or fix the design's operation_id",
"jerrycan docs modules",
));
}
}
for sub in &m.subroutes {
lint_handlers(
root,
sub,
&format!("{src_rel}/subroutes/{}", sub.name.replace('-', "_")),
out,
);
}
}
fn lint_generated_drift(root: &Path, design: &Design, out: &mut Vec<Diagnostic>) {
let drift = d(
"JL0003",
Some("crates/app/src/main.rs".into()),
None,
"generated file drifted from the design (hand-edited, or design.json changed without regenerating)".into(),
"run `jerrycan generate route <module>` to regenerate mounting; never hand-edit GENERATED files",
"jerrycan docs app#anti-patterns",
);
let main_rel = "crates/app/src/main.rs";
let on_disk = std::fs::read_to_string(root.join(main_rel)).unwrap_or_default();
if on_disk != mounting::expected_main(design) {
out.push(drift);
}
if design.wants_db()
&& let Ok(Some(expected)) = mounting::expected_migrations_rs(root, design)
{
let mig_rel = "crates/app/src/migrations.rs";
let on_disk = std::fs::read_to_string(root.join(mig_rel)).unwrap_or_default();
if on_disk != expected {
out.push(d(
"JL0003",
Some(mig_rel.into()),
None,
"generated file drifted from the design (hand-edited, or migrations changed without regenerating)".into(),
"run `jerrycan generate route <module>` to regenerate the migration aggregate; never hand-edit GENERATED files",
"jerrycan docs app#anti-patterns",
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tenant_design() -> Design {
serde_json::from_str(super::super::design::tests::V1_FULL).unwrap()
}
#[test]
fn jl0006_flags_unscoped_repo_call_not_the_scoped_one() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let handlers = root.join("crates/routes/leads/src/handlers.rs");
std::fs::create_dir_all(handlers.parent().unwrap()).unwrap();
let content = "\
use super::repo::*;
async fn show_lead(repo: Dep<LeadRepo>) -> Result<()> {
let leaked = repo.get(id).await?;
let _ = leaked;
let scoped = repo.get_for(tenant.id(), id).await?;
Ok(())
}
";
std::fs::write(&handlers, content).unwrap();
let design = tenant_design();
let hits = jl0006_only(root, &design);
assert_eq!(
hits.len(),
1,
"exactly one unscoped call, the scoped one is clean: {hits:?}"
);
let only = &hits[0];
assert_eq!(only.code, "JL0006");
assert_eq!(only.line, Some(3), "must point at the `repo.get(` line");
assert!(
only.file
.as_deref()
.unwrap()
.contains("leads/src/handlers.rs"),
"{only:?}"
);
assert!(
only.suggestion
.as_deref()
.unwrap()
.contains("all_for/get_for/remove_for"),
"carries the registered fix text: {only:?}"
);
}
#[test]
fn jl0006_silent_when_handlers_use_scoped_accessors() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let handlers = root.join("crates/routes/leads/src/handlers.rs");
std::fs::create_dir_all(handlers.parent().unwrap()).unwrap();
std::fs::write(
&handlers,
"async fn list_leads(repo: Dep<LeadRepo>) -> Result<()> {\n let _ = repo.all_for(tenant.id()).await?;\n Ok(())\n}\n",
)
.unwrap();
let design = tenant_design();
assert!(
jl0006_only(root, &design).is_empty(),
"scoped-only handlers are clean"
);
}
fn jl0006_only(root: &Path, design: &Design) -> Vec<Diagnostic> {
run(root, design)
.into_iter()
.filter(|d| d.code == "JL0006")
.collect()
}
fn per_user_design() -> Design {
serde_json::from_value(serde_json::json!({
"name": "fitness-api",
"contract_version": 1,
"auth": { "model": "session", "roles": ["user"] },
"dependencies": ["db", "auth"],
"modules": [{
"name": "workouts",
"entities": [{
"name": "Workout",
"belongs_to": [{ "entity": "User", "on_delete": "cascade" }],
"fields": [{ "name": "distance", "type": "float" }]
}],
"endpoints": [{
"operation_id": "list_workouts", "method": "GET", "path": "/",
"auth_required": true,
"success": { "status": 200, "entity": "Workout", "list": true }
}]
}]
}))
.unwrap()
}
#[test]
fn jl0006_flags_unscoped_call_on_a_per_user_identity_module() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let handlers = root.join("crates/routes/workouts/src/handlers.rs");
std::fs::create_dir_all(handlers.parent().unwrap()).unwrap();
std::fs::write(
&handlers,
"async fn list_workouts(repo: Dep<WorkoutRepo>) -> Result<()> {\n let _ = repo.all().await?;\n Ok(())\n}\n",
)
.unwrap();
let hits = jl0006_only(root, &per_user_design());
assert_eq!(
hits.len(),
1,
"one unscoped call on a per-user repo: {hits:?}"
);
assert_eq!(hits[0].line, Some(2), "points at the `repo.all()` line");
assert!(
hits[0].message.contains("another user's rows"),
"names the cross-USER leak, not cross-tenant: {:?}",
hits[0]
);
assert!(
hits[0]
.suggestion
.as_deref()
.unwrap()
.contains("_user.0.id"),
"carries the owner-scoped fix: {:?}",
hits[0]
);
}
#[test]
fn jl0006_silent_on_owner_scoped_per_user_handler() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let handlers = root.join("crates/routes/workouts/src/handlers.rs");
std::fs::create_dir_all(handlers.parent().unwrap()).unwrap();
std::fs::write(
&handlers,
"async fn list_workouts(repo: Dep<WorkoutRepo>, _user: CurrentUser) -> Result<()> {\n let _ = repo.all_for(_user.0.id).await?;\n Ok(())\n}\n",
)
.unwrap();
assert!(
jl0006_only(root, &per_user_design()).is_empty(),
"owner-scoped per-user handler is clean"
);
}
#[test]
fn jl0006_flags_bare_insert_on_a_flat_tenant_module() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let handlers = root.join("crates/routes/leads/src/handlers.rs");
std::fs::create_dir_all(handlers.parent().unwrap()).unwrap();
std::fs::write(
&handlers,
"async fn create_lead(repo: Dep<LeadRepo>, Json(body): Json<Lead>) -> Result<()> {\n let _ = repo.insert(body).await?;\n Ok(())\n}\n",
)
.unwrap();
let hits = jl0006_only(root, &tenant_design());
assert_eq!(
hits.len(),
1,
"bare insert on a flat tenant module: {hits:?}"
);
assert_eq!(hits[0].line, Some(2), "points at the `repo.insert(` line");
assert!(
hits[0]
.suggestion
.as_deref()
.unwrap()
.contains("create_for_memberships"),
"names the membership-checked create as the fix: {:?}",
hits[0]
);
}
#[test]
fn jl0006_insert_allow_hatch_suppresses_the_flag() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let handlers = root.join("crates/routes/leads/src/handlers.rs");
std::fs::create_dir_all(handlers.parent().unwrap()).unwrap();
std::fs::write(
&handlers,
"async fn create_lead(repo: Dep<LeadRepo>, tenant: Dep<Tenant>) -> Result<()> {\n let _ = repo.insert(row).await?; // jerrycan:allow JL0006\n Ok(())\n}\n",
)
.unwrap();
assert!(
jl0006_only(root, &tenant_design()).is_empty(),
"an explicit allow-hatch suppresses the JL0006 insert flag"
);
}
#[test]
fn jl0006_does_not_flag_insert_on_a_per_user_module() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let handlers = root.join("crates/routes/workouts/src/handlers.rs");
std::fs::create_dir_all(handlers.parent().unwrap()).unwrap();
std::fs::write(
&handlers,
"async fn create_workout(repo: Dep<WorkoutRepo>, Json(body): Json<Workout>) -> Result<()> {\n let _ = repo.insert(body).await?;\n Ok(())\n}\n",
)
.unwrap();
assert!(
jl0006_only(root, &per_user_design()).is_empty(),
"a per-user create insert is server-scoped — not a JL0006 leak"
);
}
fn auth_design_with_endpoint(endpoint: serde_json::Value) -> Design {
serde_json::from_value(serde_json::json!({
"name": "billing-api",
"contract_version": 1,
"auth": { "model": "jwt", "roles": ["owner"] },
"dependencies": ["auth"],
"modules": [{
"name": "billing",
"endpoints": [endpoint]
}]
}))
.unwrap()
}
fn jl0004_only(design: &Design) -> Vec<Diagnostic> {
let tmp = tempfile::tempdir().unwrap();
run(tmp.path(), design)
.into_iter()
.filter(|d| d.code == "JL0004")
.collect()
}
#[test]
fn jl0004_exempts_a_signature_authenticated_webhook() {
let design = auth_design_with_endpoint(serde_json::json!({
"operation_id": "stripe_webhook",
"method": "POST",
"path": "/webhook",
"success": { "status": 200 },
"errors": [{ "status": 400, "when": "Stripe signature is missing or invalid" }]
}));
assert!(
jl0004_only(&design).is_empty(),
"a signature-authed webhook is intentionally not JWT-guarded"
);
}
#[test]
fn jl0004_exempts_a_public_credential_issuing_route() {
let design = auth_design_with_endpoint(serde_json::json!({
"operation_id": "register",
"method": "POST",
"path": "/register",
"public": true,
"success": { "status": 201 },
"errors": [{ "status": 422, "when": "request body fails validation" }]
}));
assert!(
jl0004_only(&design).is_empty(),
"a public credential-issuing route is intentionally unguarded"
);
}
#[test]
fn jl0004_flags_the_same_route_without_public() {
let design = auth_design_with_endpoint(serde_json::json!({
"operation_id": "register",
"method": "POST",
"path": "/register",
"success": { "status": 201 },
"errors": [{ "status": 422, "when": "request body fails validation" }]
}));
let hits = jl0004_only(&design);
assert_eq!(
hits.len(),
1,
"without public, an unguarded mutation still trips JL0004: {hits:?}"
);
assert!(hits[0].message.contains("register"), "{:?}", hits[0]);
}
fn boundary_design() -> Design {
serde_json::from_value(serde_json::json!({
"name": "leads-api",
"contract_version": 1,
"modules": [{
"name": "leads",
"endpoints": [{
"operation_id": "list_leads", "method": "GET", "path": "/",
"success": { "status": 200 }
}],
"subroutes": [{
"name": "audit",
"endpoints": [{
"operation_id": "list_audit", "method": "GET", "path": "/",
"success": { "status": 200 }
}]
}]
}]
}))
.unwrap()
}
fn jl0007_only(root: &Path, design: &Design) -> Vec<Diagnostic> {
run(root, design)
.into_iter()
.filter(|d| d.code == "JL0007")
.collect()
}
fn write_at(root: &Path, rel: &str, content: &str) {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, content).unwrap();
}
#[test]
fn jl0007_flags_process_in_handlers() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_at(
root,
"crates/routes/leads/src/handlers.rs",
"async fn run_it() {\n let _ = std::process::Command::new(\"curl\");\n}\n",
);
let hits = jl0007_only(root, &boundary_design());
assert_eq!(hits.len(), 1, "exactly one boundary escape: {hits:?}");
assert_eq!(hits[0].code, "JL0007");
assert_eq!(hits[0].line, Some(2), "points at the std::process:: line");
assert!(
hits[0]
.file
.as_deref()
.unwrap()
.contains("leads/src/handlers.rs"),
"{:?}",
hits[0]
);
}
#[test]
fn jl0007_flags_fs_net_across_the_agent_owned_set() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_at(
root,
"crates/routes/leads/src/repo.rs",
"fn load() {\n let _ = std::fs::read_to_string(\"/etc/passwd\");\n}\n",
);
write_at(
root,
"crates/routes/leads/src/deps.rs",
"fn dial() {\n let _ = std::net::TcpStream::connect(\"10.0.0.1:80\");\n}\n",
);
write_at(
root,
"crates/routes/leads/src/subroutes/audit/handlers.rs",
"async fn beam() {\n let _ = tokio::fs::read(\"x\").await;\n}\n",
);
let hits = jl0007_only(root, &boundary_design());
assert_eq!(hits.len(), 3, "fs + net + tokio::fs: {hits:?}");
let files: BTreeSet<&str> = hits.iter().map(|h| h.file.as_deref().unwrap()).collect();
assert!(files.iter().any(|f| f.contains("repo.rs")), "{files:?}");
assert!(files.iter().any(|f| f.contains("deps.rs")), "{files:?}");
assert!(
files
.iter()
.any(|f| f.contains("subroutes/audit/handlers.rs")),
"subroute files are scanned: {files:?}"
);
}
#[test]
fn jl0007_allow_hatch_is_line_scoped() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_at(
root,
"crates/routes/leads/src/handlers.rs",
"async fn x() {\n let _ = std::process::Command::new(\"ok\"); // jerrycan:allow JL0007\n let _ = std::process::Command::new(\"bad\");\n}\n",
);
let hits = jl0007_only(root, &boundary_design());
assert_eq!(hits.len(), 1, "only the un-allowed line flags: {hits:?}");
assert_eq!(hits[0].line, Some(3), "the next line still flags");
}
#[test]
fn jl0007_silent_on_legitimate_code() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_at(
root,
"crates/routes/leads/src/handlers.rs",
"use std::fmt;\nuse std::collections::HashMap;\n// we never call std::process::Command here\nasync fn x() {\n let _ = jerrycan::prelude::Json::default();\n let _: HashMap<u8, u8> = HashMap::new();\n let _ = sea_orm::EntityTrait::find();\n}\n",
);
assert!(
jl0007_only(root, &boundary_design()).is_empty(),
"no boundary escape in legitimate code"
);
}
#[test]
fn jl0004_still_flags_a_plain_unguarded_mutation() {
let design = auth_design_with_endpoint(serde_json::json!({
"operation_id": "create_charge",
"method": "POST",
"path": "/charges",
"success": { "status": 201 },
"errors": [{ "status": 400, "when": "request body is malformed" }]
}));
let hits = jl0004_only(&design);
assert_eq!(
hits.len(),
1,
"a non-signature 400 is no exemption: {hits:?}"
);
assert!(hits[0].message.contains("create_charge"), "{:?}", hits[0]);
}
}