use super::checkpipe::Diagnostic;
use super::design::{Design, HandlerRef, HttpMethod, ModuleDesign};
use super::mounting;
use std::collections::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 tenant = design.tenant_owned_handlers();
let covered: BTreeSet<&str> = tenant.iter().map(|h| h.rel_path.as_str()).collect();
for h in &tenant {
scan_unscoped(root, h, true, true, out);
}
for module in identity_owned_modules(design) {
let rel = format!("crates/routes/{module}/src/handlers.rs");
if covered.contains(rel.as_str()) {
continue;
}
let reads_public = design
.modules
.iter()
.find(|m| m.name == module)
.is_some_and(|m| {
m.entities
.iter()
.filter(|e| design.entity_is_per_user_owned(e))
.all(|e| design.entity_is_public_read(&e.name))
});
let h = HandlerRef {
rel_path: rel,
is_flat: false,
owned_desc: "an identity-owned",
leak_desc: "another user's rows",
suggestion: if reads_public {
"route the write through the owner-scoped accessors (update_for/remove_for) with the session user's id (_user.0.id); reads are public on this public_read module".to_string()
} else {
"call the owner-scoped accessor (all_for/get_for/remove_for) with the session user's id (_user.0.id)".to_string()
},
};
scan_unscoped(root, &h, false, !reads_public, out);
}
}
fn scan_unscoped(
root: &Path,
h: &HandlerRef,
fail_loud: bool,
flag_reads: bool,
out: &mut Vec<Diagnostic>,
) {
let content = match std::fs::read_to_string(root.join(&h.rel_path)) {
Ok(c) => c,
Err(_) => {
if fail_loud {
out.push(jl0008(&h.rel_path));
}
return;
}
};
let ast = match syn::parse_file(&content) {
Ok(f) => f,
Err(_) => {
if fail_loud {
out.push(jl0008(&h.rel_path));
}
return;
}
};
let src: Vec<&str> = content.lines().collect();
let mut v = UnscopedVisitor {
hits: Vec::new(),
flag_insert: h.is_flat,
flag_reads,
src: &src,
};
syn::visit::Visit::visit_file(&mut v, &ast);
for (line, call) in v.hits {
out.push(d(
"JL0006",
Some(h.rel_path.clone()),
Some(line as u64),
format!(
"handler calls the unscoped `repo.{call}` on {} repo — it can read, write, or delete {}",
h.owned_desc, h.leak_desc
),
&h.suggestion,
"jerrycan docs database",
));
}
}
fn jl0008(rel: &str) -> Diagnostic {
d(
"JL0008",
Some(rel.to_string()),
None,
format!(
"tenant-owned handler `{rel}` could not be scanned for scoping — it is missing, unreadable, or not valid Rust, so an unscoped cross-tenant call could pass unseen"
),
"ensure the handler file exists and compiles (run `cargo check`); a scaffold is generated parseable — if you hand-edited it, fix the syntax so `jerrycan check` can verify tenant scoping",
"jerrycan docs database",
)
}
fn receiver_is_repo(expr: &syn::Expr) -> bool {
match expr {
syn::Expr::Path(p) => p.path.is_ident("repo"),
syn::Expr::Paren(p) => receiver_is_repo(&p.expr),
syn::Expr::Group(g) => receiver_is_repo(&g.expr),
syn::Expr::Reference(r) => receiver_is_repo(&r.expr),
_ => false,
}
}
struct UnscopedVisitor<'a> {
hits: Vec<(usize, &'static str)>,
flag_insert: bool,
flag_reads: bool,
src: &'a [&'a str],
}
impl<'ast> syn::visit::Visit<'ast> for UnscopedVisitor<'_> {
fn visit_expr_method_call(&mut self, c: &'ast syn::ExprMethodCall) {
let name = c.method.to_string();
let display = match name.as_str() {
"all" if c.args.is_empty() && self.flag_reads => Some("all()"),
"get" if self.flag_reads => Some("get(...)"),
"remove" => Some("remove(...)"),
"update" => Some("update(...)"),
"insert" if self.flag_insert => Some("insert(...)"),
_ => None,
};
if let Some(display) = display
&& receiver_is_repo(&c.receiver)
{
let line = c.method.span().start().line;
let allowed = self
.src
.get(line.saturating_sub(1))
.is_some_and(|l| l.trim_end().ends_with("// jerrycan:allow JL0006"));
if !allowed {
self.hits.push((line, display));
}
}
syn::visit::visit_expr_method_call(self, c);
}
fn visit_expr_macro(&mut self, node: &'ast syn::ExprMacro) {
self.scan_macro(&node.mac);
syn::visit::visit_expr_macro(self, node);
}
fn visit_stmt_macro(&mut self, node: &'ast syn::StmtMacro) {
self.scan_macro(&node.mac);
syn::visit::visit_stmt_macro(self, node);
}
fn visit_item_macro(&mut self, node: &'ast syn::ItemMacro) {
self.scan_macro(&node.mac);
syn::visit::visit_item_macro(self, node);
}
}
impl UnscopedVisitor<'_> {
fn scan_macro(&mut self, mac: &syn::Macro) {
let tokens: String = mac.tokens.to_string().split_whitespace().collect();
let mut needles: Vec<(&str, &'static str)> = Vec::new();
if self.flag_reads {
needles.push(("repo.all()", "all()"));
needles.push(("repo.get(", "get(...)"));
}
needles.push(("repo.remove(", "remove(...)"));
needles.push(("repo.update(", "update(...)"));
if self.flag_insert {
needles.push(("repo.insert(", "insert(...)"));
}
let matched: Vec<&'static str> = needles
.iter()
.filter(|(needle, _)| tokens.contains(needle))
.map(|(_, display)| *display)
.collect();
if matched.is_empty() {
return;
}
let line = mac
.path
.segments
.last()
.map_or(1, |s| s.ident.span().start().line);
let allowed = self
.src
.get(line.saturating_sub(1))
.is_some_and(|l| l.trim_end().ends_with("// jerrycan:allow JL0006"));
if allowed {
return;
}
for display in matched {
self.hits.push((line, display));
}
}
}
fn identity_owned_modules(design: &Design) -> BTreeSet<&str> {
let mut out = BTreeSet::new();
for m in &design.modules {
let has_per_user = m
.entities
.iter()
.any(|e| design.entity_is_per_user_owned(e));
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 public_read_design() -> Design {
let mut d = per_user_design();
d.modules[0].entities[0].public_read = true;
d
}
#[test]
fn jl0006_public_read_module_skips_reads_but_flags_writes() {
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 let _ = repo.get(7).await?;\n let _ = serde_json::json!({ \"rows\": repo.all().await? });\n Ok(())\n}\nasync fn update_workout(repo: Dep<WorkoutRepo>) -> Result<()> {\n let _ = repo.update(7, item).await?;\n let _ = repo.remove(7).await?;\n Ok(())\n}\n",
)
.unwrap();
let hits = jl0006_only(root, &public_read_design());
assert_eq!(
hits.len(),
2,
"only the WRITE needles fire on a public_read module: {hits:?}"
);
assert_eq!(hits[0].line, Some(8), "the `repo.update(` line: {hits:?}");
assert_eq!(hits[1].line, Some(9), "the `repo.remove(` line: {hits:?}");
assert!(
hits.iter().all(|h| h
.suggestion
.as_deref()
.unwrap()
.contains("update_for/remove_for")),
"steers writes to the owner-scoped write accessors: {hits:?}"
);
}
#[test]
fn jl0006_macro_scanner_keeps_write_needles_on_a_public_read_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 delete_workout(repo: Dep<WorkoutRepo>) -> Result<()> {\n let _ = serde_json::json!({ \"gone\": repo.remove(7).await? });\n Ok(())\n}\n",
)
.unwrap();
let hits = jl0006_only(root, &public_read_design());
assert_eq!(
hits.len(),
1,
"the macro-wrapped unscoped write must be flagged even with the read \
needles disarmed: {hits:?}"
);
assert_eq!(hits[0].line, Some(2), "points at the macro line: {hits:?}");
assert!(
hits[0].message.contains("remove(...)"),
"names the write needle: {:?}",
hits[0]
);
}
#[test]
fn jl0006_mixed_module_keeps_the_read_needles() {
let mut design = public_read_design();
design.modules[0].entities.push(
serde_json::from_value(serde_json::json!({
"name": "Meal",
"belongs_to": [{ "entity": "User", "on_delete": "cascade" }],
"fields": [{ "name": "calories", "type": "integer" }]
}))
.unwrap(),
);
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_meals(repo: Dep<MealRepo>) -> Result<()> {\n let _ = repo.all().await?;\n Ok(())\n}\n",
)
.unwrap();
let hits = jl0006_only(root, &design);
assert_eq!(
hits.len(),
1,
"a mixed module keeps flagging unscoped reads: {hits:?}"
);
}
#[test]
fn jl0004_still_fires_on_an_unguarded_write_in_a_public_read_design() {
let mut design = public_read_design();
design.modules[0].endpoints.push(
serde_json::from_value(serde_json::json!({
"operation_id": "create_workout", "method": "POST", "path": "/",
"request_body": { "entity": "Workout" },
"success": { "status": 201, "entity": "Workout" }
}))
.unwrap(),
);
let hits = jl0004_only(&design);
assert_eq!(
hits.len(),
1,
"public_read never exempts an unguarded write: {hits:?}"
);
}
fn nested_grandchild_design() -> Design {
serde_json::from_value(serde_json::json!({
"name": "org-api",
"contract_version": 1,
"auth": { "model": "session", "roles": ["owner", "member"] },
"dependencies": ["db", "auth"],
"tenancy": { "entity": "Org", "member_roles": ["owner", "member"] },
"modules": [
{ "name": "orgs",
"entities": [{ "name": "Org", "fields": [{ "name": "id", "type": "integer" }] }],
"endpoints": [{ "operation_id": "list_orgs", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "Org", "list": true } }] },
{ "name": "accounts",
"entities": [{ "name": "Account",
"belongs_to": [{ "entity": "Org" }],
"fields": [{ "name": "id", "type": "integer" }] }],
"endpoints": [{ "operation_id": "list_accounts", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "Account", "list": true } }],
"subroutes": [
{ "name": "contacts",
"entities": [{ "name": "Contact",
"belongs_to": [{ "entity": "Account" }],
"fields": [{ "name": "id", "type": "integer" }] }],
"endpoints": [{ "operation_id": "show_contact", "method": "GET", "path": "/{id}",
"success": { "status": 200, "entity": "Contact" } }] }
] }
]
}))
.unwrap()
}
#[test]
fn jl0006_fires_on_unscoped_call_in_nested_handler() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let rel = "crates/routes/accounts/src/subroutes/contacts/handlers.rs";
let handlers = root.join(rel);
std::fs::create_dir_all(handlers.parent().unwrap()).unwrap();
std::fs::write(
&handlers,
"async fn show_contact(repo: Dep<ContactRepo>) -> Result<()> {\n let _ = repo.get(id).await?;\n Ok(())\n}\n",
)
.unwrap();
std::fs::write(
root.join("crates/routes/accounts/src/handlers.rs"),
"async fn list_accounts(repo: Dep<AccountRepo>) -> Result<()> {\n let _ = repo.all_for(_tenant.id()).await?;\n Ok(())\n}\n",
)
.unwrap();
let diags = run(root, &nested_grandchild_design());
assert!(
diags
.iter()
.any(|d| d.code == "JL0006" && d.file.as_deref() == Some(rel) && d.line == Some(2)),
"JL0006 must reach the NESTED grandchild handler (was silently skipped, #103): {diags:?}"
);
}
#[test]
fn jl0006_fires_on_unscoped_call_inside_a_macro_in_nested_handler() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let rel = "crates/routes/accounts/src/subroutes/contacts/handlers.rs";
let handlers = root.join(rel);
std::fs::create_dir_all(handlers.parent().unwrap()).unwrap();
std::fs::write(
&handlers,
"async fn show_contact(repo: Dep<ContactRepo>) -> Result<()> {\n Ok(Json(serde_json::json!({ \"items\": repo.all().await? })))\n}\n",
)
.unwrap();
std::fs::write(
root.join("crates/routes/accounts/src/handlers.rs"),
"async fn list_accounts(repo: Dep<AccountRepo>) -> Result<()> {\n let _ = repo.all_for(_tenant.id()).await?;\n Ok(())\n}\n",
)
.unwrap();
let diags = run(root, &nested_grandchild_design());
assert!(
diags
.iter()
.any(|d| d.code == "JL0006" && d.file.as_deref() == Some(rel) && d.line == Some(2)),
"JL0006 must reach the unscoped repo.all() inside the json! macro — syn::visit does not descend into macro tokens: {diags:?}"
);
}
#[test]
fn jl0006_silent_on_scoped_call_inside_a_macro() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let rel = "crates/routes/accounts/src/subroutes/contacts/handlers.rs";
let handlers = root.join(rel);
std::fs::create_dir_all(handlers.parent().unwrap()).unwrap();
std::fs::write(
&handlers,
"async fn show_contact(repo: Dep<ContactRepo>, u: CurrentUser) -> Result<()> {\n Ok(Json(serde_json::json!({ \"items\": repo.all_for_memberships(u).await? })))\n}\n",
)
.unwrap();
std::fs::write(
root.join("crates/routes/accounts/src/handlers.rs"),
"async fn list_accounts(repo: Dep<AccountRepo>) -> Result<()> {\n let _ = repo.all_for(_tenant.id()).await?;\n Ok(())\n}\n",
)
.unwrap();
let diags = run(root, &nested_grandchild_design());
assert!(
!diags.iter().any(|d| d.code == "JL0006"),
"a scoped all_for_memberships inside a macro must not fire JL0006: {diags:?}"
);
}
fn lints_for_leads_body(body: &str) -> Vec<Diagnostic> {
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,
format!(
"async fn h(repo: Dep<LeadRepo>) -> Result<()> {{\n {body}\n Ok(())\n}}\n"
),
)
.unwrap();
run(root, &tenant_design())
}
#[test]
fn jl0006_ast_ignores_repo_all_in_a_comment() {
let diags = lints_for_leads_body(
"// repo.all() is the unscoped call we must avoid\n let _x = repo.all_for_memberships(u).await?;",
);
assert!(
!diags.iter().any(|d| d.code == "JL0006"),
"a mention in a comment is not a call: {diags:?}"
);
}
#[test]
fn jl0006_ast_catches_multiline_chain() {
let diags = lints_for_leads_body("let _x = repo\n .all()\n .await?;");
assert!(
diags.iter().any(|d| d.code == "JL0006"),
"multi-line chain must be caught (substring scan missed it): {diags:?}"
);
}
#[test]
fn jl0008_when_tenant_owned_handler_unparseable() {
let diags = lints_for_leads_body("fn broken( {{{ this does not parse");
assert!(
diags.iter().any(|d| d.code == "JL0008"
&& d.file.as_deref() == Some("crates/routes/leads/src/handlers.rs")),
"unparseable tenant-owned handler → loud JL0008, never a silent skip: {diags:?}"
);
}
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]);
}
}