mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 17:10:44 -10:00
fix(workspace): narrow home state exemption to the state directory
Second review pass on the home-workspace exemption. Three small corrections, all in the boundary the first two commits established. `workspace_state_path` shared the resolution rule with `workspace_state_dir` by copying it; both now come from one `resolved_home_state`, so the two cannot drift into disagreeing about where state lives. The function also asserts its `relative` argument is a literal relative path. Every caller passes components it wrote itself, and a `relative` carrying `..` would step outside the state directory the root exists to protect. The skills exemption matched only the resolved target of the state directory, so a user who put `~/.codewhale/skills` itself on another volume lost the exemption and silently lost those skills. It now matches the literal spelling under the state directory as well: that directory is the user's own application state, not repository content, so a link inside it stays exempt whichever end is linked. The journal's `root` field no longer claims to be the state directory in every case; for a non-home workspace it is the workspace. Verified on Windows with junctions on both sides: a non-home workspace still refuses `<repo>/.codewhale`, a home workspace still passes `<home>/.codewhale` and walks the resolved target. - cargo check -p codewhale-util -p codewhale-skills: passed - cargo check -p codewhale-engine: passed - cargo test -p codewhale-util: 41 passed; 0 failed - cargo test -p codewhale-skills: 174 passed; 0 failed - cargo test -p codewhale-engine --lib -- tools::workflow::journal fleet::profile: 41 passed; 0 failed - cargo test -p codewhale-engine --lib -- subagent::tests::: 590 passed; 6 failed, the same six that fail on the parent commit with these changes stashed (real-home skill leakage into a tempdir workspace, plus one test that overflows the default Windows test stack). - cargo fmt --all -- --check: passed
This commit is contained in:
1 parent
0ce030840b
commit
5ad3d821c7
3 files changed
+50
-20
No files matched your search
@@ -178,7 +178,9 @@ enum WorkflowJournalRecord {
|
||||
#[derive(Debug)]
|
||||
struct WorkflowRunJournal {
|
||||
/// State directory the ledger must stay inside; links below it are refused.
|
||||
/// See [`WorkflowRunJournal::open`] for which root a workspace gets.
|
||||
/// A home workspace makes this the resolved `~/.codewhale` target, while
|
||||
/// every other workspace passes itself here with `.codewhale` still inside
|
||||
/// the ledger path. See [`WorkflowRunJournal::open`].
|
||||
root: PathBuf,
|
||||
ledger_path: PathBuf,
|
||||
}
|
||||
|
||||
@@ -1248,15 +1248,24 @@ fn merge_watched_directories(
|
||||
let mut seen_files = HashSet::new();
|
||||
// A home workspace is not a repository, but only its own Codewhale state
|
||||
// directory is exempt: the user may keep `~/.codewhale` behind a junction,
|
||||
// while every other directory under home stays workspace content and goes
|
||||
// on refusing links that escape it.
|
||||
let home_state = workspace
|
||||
// or put one of its skills directories on another volume, while every other
|
||||
// directory under home stays workspace content and goes on refusing links
|
||||
// that escape it. The state directory is matched by its literal spelling as
|
||||
// well, so a linked directory *inside* the user's own state root keeps the
|
||||
// exemption that root exists for.
|
||||
let home = workspace
|
||||
.filter(|workspace| codewhale_util::files::workspace_is_user_home(workspace))
|
||||
.map(codewhale_util::files::workspace_state_dir);
|
||||
.map(|workspace| {
|
||||
(
|
||||
workspace.join(codewhale_paths::CODEWHALE_APP_DIR),
|
||||
codewhale_util::files::workspace_state_dir(workspace),
|
||||
)
|
||||
});
|
||||
for dir in dirs {
|
||||
watched.push((dir.clone(), watched_path_stamp(&dir)));
|
||||
let owned_state_dir = home_state.as_ref().is_some_and(|state| {
|
||||
fs::canonicalize(&dir).is_ok_and(|resolved| resolved.starts_with(state))
|
||||
let owned_state_dir = home.as_ref().is_some_and(|(literal, resolved)| {
|
||||
dir.starts_with(literal)
|
||||
|| fs::canonicalize(&dir).is_ok_and(|target| target.starts_with(resolved))
|
||||
});
|
||||
let confine_to =
|
||||
workspace.filter(|workspace| dir.starts_with(workspace) && !owned_state_dir);
|
||||
|
||||
+32
-13
@@ -44,15 +44,24 @@ fn same_directory(left: &Path, right: &Path) -> bool {
|
||||
#[must_use]
|
||||
pub fn workspace_state_dir(workspace: &Path) -> std::path::PathBuf {
|
||||
let state = workspace.join(codewhale_paths::CODEWHALE_APP_DIR);
|
||||
if workspace_is_user_home(workspace) {
|
||||
state.canonicalize().unwrap_or(state)
|
||||
} else {
|
||||
state
|
||||
resolved_home_state(workspace).unwrap_or(state)
|
||||
}
|
||||
|
||||
/// `workspace`'s state directory as its real path, for a home workspace whose
|
||||
/// `~/.codewhale` resolves. `None` is every other case: another workspace, a
|
||||
/// missing directory (first run), or a link that cannot be reached right now.
|
||||
fn resolved_home_state(workspace: &Path) -> Option<std::path::PathBuf> {
|
||||
if !workspace_is_user_home(workspace) {
|
||||
return None;
|
||||
}
|
||||
workspace
|
||||
.join(codewhale_paths::CODEWHALE_APP_DIR)
|
||||
.canonicalize()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Confinement root and state path for `relative` beneath the workspace's
|
||||
/// Codewhale state directory.
|
||||
/// Codewhale state directory. The tuple is `(root, path)`.
|
||||
///
|
||||
/// The root is the boundary a link walk starts from, and
|
||||
/// [`reject_linked_path`] never examines the root itself — only what is below
|
||||
@@ -64,18 +73,23 @@ pub fn workspace_state_dir(workspace: &Path) -> std::path::PathBuf {
|
||||
/// - Every other workspace keeps itself as the root with `.codewhale` still
|
||||
/// inside the path, so a repository-supplied link is refused exactly as it
|
||||
/// was before this helper existed.
|
||||
///
|
||||
/// `relative` is joined as given. Every caller passes literal path components
|
||||
/// it wrote itself; a `relative` carrying `..` or an absolute path would step
|
||||
/// outside the state directory, so this is asserted for those callers rather
|
||||
/// than defended against a caller that does not exist.
|
||||
#[must_use]
|
||||
pub fn workspace_state_path(
|
||||
workspace: &Path,
|
||||
relative: &Path,
|
||||
) -> (std::path::PathBuf, std::path::PathBuf) {
|
||||
let state = workspace.join(codewhale_paths::CODEWHALE_APP_DIR);
|
||||
let within_workspace = || (workspace.to_path_buf(), state.join(relative));
|
||||
if !workspace_is_user_home(workspace) {
|
||||
return within_workspace();
|
||||
}
|
||||
match state.canonicalize() {
|
||||
Ok(resolved) => {
|
||||
debug_assert!(
|
||||
path_is_confined(relative),
|
||||
"workspace state path must be a relative path of literal components: {}",
|
||||
relative.display()
|
||||
);
|
||||
match resolved_home_state(workspace) {
|
||||
Some(resolved) => {
|
||||
let path = resolved.join(relative);
|
||||
(resolved, path)
|
||||
}
|
||||
@@ -83,7 +97,12 @@ pub fn workspace_state_path(
|
||||
// workspace itself. A directory that exists but cannot be resolved fails
|
||||
// closed against the strict root rather than writing a second state
|
||||
// tree beside the one that cannot be reached.
|
||||
Err(_) => within_workspace(),
|
||||
None => (
|
||||
workspace.to_path_buf(),
|
||||
workspace
|
||||
.join(codewhale_paths::CODEWHALE_APP_DIR)
|
||||
.join(relative),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user