{"record":{"id":"b4db4df5f516d778","repo":"astrid-runtime/astrid","slug":"workspace-selection-changed-after-validation","errorCode":null,"errorMessage":"workspace selection changed after validation","messagePattern":"workspace selection changed after validation","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/astrid-core/src/workspace_security.rs","lineNumber":233,"sourceCode":"            }\n        }\n        Ok(self.state_dir.join(relative))\n    }\n\n    /// Re-check that the selected state path has not been redirected.\n    ///\n    /// A missing state directory remains valid. This permits a checked\n    /// selection to be created before initialization while still rejecting a\n    /// later symlink or non-directory replacement.\n    ///\n    /// # Errors\n    ///\n    /// Returns an error if the project root or state path no longer satisfies\n    /// the original selection.\n    pub fn verify(&self) -> io::Result<()> {\n        let current = Self::resolve(&self.project_root, self.layout.clone())?;\n        if current.project_root != self.project_root || current.state_dir != self.state_dir {\n            return Err(io::Error::new(\n                io::ErrorKind::InvalidData,\n                \"workspace selection changed after validation\",\n            ));\n        }\n        Ok(())\n    }\n\n    /// Create the selected state directory and verify it again afterwards.\n    ///\n    /// # Errors\n    ///\n    /// Returns an error if creation fails or the path is redirected before or\n    /// after creation.\n    pub fn ensure_state_dir(&self) -> io::Result<()> {\n        self.verify()?;\n        #[cfg(windows)]\n        {\n            crate::platform_fs::ensure_private_directory(&self.state_dir)?;","sourceCodeStart":215,"sourceCodeEnd":251,"githubUrl":"https://github.com/astrid-runtime/astrid/blob/affd8760f44190dbdfbec23403f4c4b642c33112/crates/astrid-core/src/workspace_security.rs#L215-L251","documentation":"WorkspaceSelection::verify re-resolves the stored project_root and compares the freshly computed project_root and state_dir against the values captured at selection time; any difference raises InvalidData 'workspace selection changed after validation'. It is a TOCTOU guard: if the workspace root was moved, replaced, re-mounted, or its state path re-pointed after the original validation, subsequent operations are refused rather than acting on an untrusted tree. verify is invoked by resolve_descendant, ensure_state_dir, and ensure_directory before touching the filesystem.","triggerScenarios":"Calling resolve_directory/resolve_file/ensure_state_dir/ensure_directory after the workspace root was renamed, deleted-and-recreated at a different canonical path, re-mounted, or when a symlink/hash preimage changed what resolve() computes for state_dir (layout-dependent state paths shifting because the canonical root changed).","commonSituations":"Long-lived daemon holding a WorkspaceSelection while the project is moved or checked out afresh (new inode, canonical path changes via symlink retarget); containers restarting with volumes re-mounted elsewhere; editors/agents swapping project directories mid-session; NFS path changes.","solutions":["Re-create the WorkspaceSelection (call WorkspaceSelection::resolve again) and retry the operation with the fresh selection.","Do not cache a selection across workspace moves; re-resolve whenever the project root may have changed.","Check for concurrent processes moving or re-mounting the workspace and serialize such maintenance with library usage.","If the root is deliberately remapped, update all held selections to the new canonical root instead of reusing stale ones."],"exampleFix":"// before: reuse stale selection after moving the project\nstd::fs::rename(\"/srv/app\", \"/srv/app-v2\");\nws.ensure_directory(Path::new(\"out\"))?; // fails: selection changed\n// after\nlet ws = WorkspaceSelection::resolve(Path::new(\"/srv/app-v2\"), layout)?;\nws.ensure_directory(Path::new(\"out\"))?;","handlingStrategy":"try-catch","validationCode":"let fresh = WorkspaceSelection::resolve(&sel.project_root, sel.layout.clone())?;\nlet still_valid = fresh.project_root == sel.project_root && fresh.state_dir == sel.state_dir;","typeGuard":null,"tryCatchPattern":"match op(&ws) {\n    Err(e) if e.to_string().contains(\"workspace selection changed after validation\") => {\n        let ws = WorkspaceSelection::resolve(&root, layout.clone())?;\n        op(&ws)?\n    }\n    other => other?,\n}","preventionTips":["Re-create the WorkspaceSelection after any project move, re-checkout, or remount","Don't cache selections across long-running sessions that outlive workspace changes","Serialize workspace moves/renames with library usage (locks, shutdown hooks)"],"tags":["security","toctou","workspace","race-condition","validation"],"backgroundTag":"invalid-state-transition","analyzedSha":"affd8760f44190dbdfbec23403f4c4b642c33112","analyzedAt":"2026-09-09T21:28:12.402Z","contentChangedAt":"2026-09-09T21:28:12.402Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}