From cf1991814b6fcf3554448183edc9bfd9f13fe579 Mon Sep 17 00:00:00 2001 From: atagen Date: Tue, 2 Jun 2026 13:35:52 +1000 Subject: [PATCH] core: add the disinherit directive to stop the parent cascade --- README.md | 3 ++ src/cli/parse.rs | 9 +++++ src/core.rs | 2 +- src/core/participants.rs | 72 +++++++++++++++++++++++++++++++++++++++- src/types.rs | 2 ++ tests/integration.rs | 67 +++++++++++++++++++++++++++++++++++++ 6 files changed, 153 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8a8207c..b5b7c9e 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,9 @@ one directive per line, `#` are comments # keeping variables inherited from parent .cade layers pure +# stop the cascade here; no parent .cade layers load above this dir +disinherit + # load from flake (default shell or named installable) load load flake diff --git a/src/cli/parse.rs b/src/cli/parse.rs index 1005322..c090ac0 100644 --- a/src/cli/parse.rs +++ b/src/cli/parse.rs @@ -62,6 +62,7 @@ impl FromStr for Keyword { let res = match keyword.as_str() { "pure" => Pure, + "disinherit" => Disinherit, "call" => { // split respecting shell quoting let target = shlex::split(rest_raw).ok_or(ParseError::InvalidQuoting)?; @@ -181,6 +182,14 @@ mod tests { )); } + #[test] + fn bare_disinherit_parses() { + assert!(matches!( + "disinherit".parse::(), + Ok(Keyword::Disinherit) + )); + } + #[test] fn keyword_with_equals_in_args_stays_a_keyword() { // the `=` belongs to the hook command, not a bare assignment diff --git a/src/core.rs b/src/core.rs index 79a721f..07146b2 100644 --- a/src/core.rs +++ b/src/core.rs @@ -1686,7 +1686,7 @@ fn load_single_layer( Concat(vars) => Ok(CadeAction::Concat(vars.clone())), Set(env) => Ok(CadeAction::Environ(env.clone())), // affects only chain construction, not the loaded environment - Watch(_) => continue, + Watch(_) | Disinherit => continue, }?; layer.push_action(act); } diff --git a/src/core/participants.rs b/src/core/participants.rs index 3b48983..1195842 100644 --- a/src/core/participants.rs +++ b/src/core/participants.rs @@ -2,6 +2,7 @@ //! activation at a given cwd, and which one is the root. No db, no env, no //! shell output - just the filesystem layout of `.cade` and `.envrc` markers. +use crate::types::Keyword; use std::path::{Path, PathBuf}; /// what a dir contributes; a co-located `.envrc` yields to `.cade`, so at most one kind @@ -21,9 +22,22 @@ fn dir_kind(dir: &Path) -> Option { } } +/// true when `dir`'s `.cade` carries a `disinherit` directive (caps the cascade) +fn reads_disinherit(dir: &Path) -> bool { + matches!( + super::read_cade(&dir.join(".cade")), + Ok(kws) if kws.iter().any(|kw| matches!(kw, Keyword::Disinherit)) + ) +} + /// the active layer set, tip-first: every `.cade` ancestor (the cascade stacks /// across gaps; an empty intermediate dir does not sever it) unioned with -/// direnv's single nearest `.envrc`. only the permission layer caps it +/// direnv's single nearest `.envrc`. `disinherit` halts the cascade; otherwise +/// only the permission layer caps it +// +// note: a `disinherit` dir is parsed here and re-parsed at activation via +// `config_keywords`; a single-parse pass shared across both is a deferred +// cross-cutting refactor (touches the composition-branch callers) pub(super) fn participant_dirs(start: &Path) -> Vec { let mut cade_chain: Vec = Vec::new(); let mut nearest_envrc: Option = None; @@ -32,7 +46,11 @@ pub(super) fn participant_dirs(start: &Path) -> Vec { while let Some(d) = dir { match dir_kind(&d) { Some(DirKind::Cade) => { + // include this dir, then stop the cascade if it disinherits cade_chain.push(d.clone()); + if reads_disinherit(&d) { + break; + } } Some(DirKind::Envrc) => { // only the nearest .envrc @@ -190,4 +208,56 @@ mod tests { assert_eq!(parts(&participant_dirs(&a), &base), vec!["a".to_string()]); std::fs::remove_dir_all(&base).ok(); } + + /// Build a temp tree from (rel-dir, filename, contents) entries. + fn build_tree(spec: &[(&str, &str, &str)], tag: &str) -> PathBuf { + use std::sync::atomic::{AtomicU32, Ordering}; + static SALT: AtomicU32 = AtomicU32::new(0); + let base = std::env::temp_dir().join(format!( + "cade-{tag}-{}-{}", + std::process::id(), + SALT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::remove_dir_all(&base).ok(); + for (rel, file, contents) in spec { + let dir = base.join(rel); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(file), contents.as_bytes()).unwrap(); + } + base + } + + #[test] + fn disinherit_truncates_the_cade_cascade() { + // child .cade disinherits, so its .cade parent never joins the chain. + let base = build_tree( + &[("a", ".cade", ""), ("a/b", ".cade", "disinherit\n")], + "disinherit", + ); + let cwd = base.join("a/b"); + assert_eq!( + parts(&participant_dirs(&cwd), &base), + vec!["a/b".to_string()] + ); + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn disinherit_still_unions_the_nearest_envrc() { + // disinherit drops the parent .cade, but a nearer .envrc still composes. + let base = build_tree( + &[ + ("a", ".cade", ""), + ("a/b", ".cade", "disinherit\n"), + ("a/b/c", ".envrc", "export X=1\n"), + ], + "disinherit-envrc", + ); + let cwd = base.join("a/b/c"); + assert_eq!( + parts(&participant_dirs(&cwd), &base), + vec!["a/b/c".to_string(), "a/b".to_string()] + ); + std::fs::remove_dir_all(&base).ok(); + } } diff --git a/src/types.rs b/src/types.rs index 04fbe2c..a6a47bb 100644 --- a/src/types.rs +++ b/src/types.rs @@ -26,6 +26,8 @@ pub struct CadeLayer { #[derive(Debug)] pub enum Keyword { Pure, + /// stop the `.cade` cascade at this dir; nothing above it loads + Disinherit, Call(Vec), Load(Loadable), Hook(InnerHook), diff --git a/tests/integration.rs b/tests/integration.rs index e2142b7..d59a800 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1749,3 +1749,70 @@ fn s6_colocated_envrc_is_ignored() { "a co-located .envrc must be ignored when .cade is present: {s}" ); } + +#[test] +fn disinherit_stops_the_parent_cascade() { + // child .cade disinherits -> its parent .cade never composes + let sb = Sandbox::new(); + sb.write("a/.cade", "A_CADE=1\n"); + let b = sb.dir("a/b"); + sb.write("a/b/.cade", "disinherit\nB_CADE=1\n"); + sb.allow(&sb.dir("a")); + sb.allow(&b); + + let out = sb.enter(&b, &[]); + assert!(out.status.success(), "{out:?}"); + let s = stdout(&out); + assert!(s.contains("export B_CADE='1';"), "{s}"); + assert!( + !s.contains("A_CADE"), + "disinherit must drop the parent .cade layer: {s}" + ); +} + +#[test] +fn disinherit_composes_with_nearest_envrc() { + // disinherit truncates the .cade cascade, but a nearer .envrc still joins + let sb = Sandbox::new(); + sb.write("a/.cade", "A_CADE=1\n"); + sb.write("a/b/.cade", "disinherit\nB_CADE=1\n"); + let c = sb.dir("a/b/c"); + sb.write("a/b/c/.envrc", "export C_ENVRC=ok\n"); + sb.allow(&sb.dir("a/b")); + sb.allow(&c); + + let out = sb.enter(&c, &[]); + assert!(out.status.success(), "{out:?}"); + let s = stdout(&out); + assert!(s.contains("export B_CADE='1';"), "{s}"); + assert!(s.contains("export C_ENVRC='ok';"), "{s}"); + assert!( + !s.contains("A_CADE"), + "disinherit must drop the .cade above it: {s}" + ); +} + +#[test] +fn allow_gap_fill_respects_disinherit_root() { + // the disinherit dir is the chain root; gap-fill never reaches above it + let sb = Sandbox::new(); + sb.write("a/.cade", "A_CADE=1\n"); + let b = sb.dir("a/b"); + sb.write("a/b/.cade", "disinherit\nB_CADE=1\n"); + let tip = sb.dir("a/b/tip"); + sb.write("a/b/tip/.cade", "TIP_CADE=1\n"); + + // approve the disinherit root as the base, then the tip + sb.allow(&b); + sb.allow(&tip); + + let out = sb.enter(&tip, &[]); + assert!(out.status.success(), "{out:?}"); + let s = stdout(&out); + assert!(s.contains("export TIP_CADE='1';"), "{s}"); + assert!(s.contains("export B_CADE='1';"), "{s}"); + assert!( + !s.contains("A_CADE"), + "disinherit caps the chain, so the parent must never compose: {s}" + ); +}