{"record":{"id":"f4d459e788c85c60","repo":"jdx/mise","slug":"remote-cache-directory-graph-contains-a-cycle","errorCode":null,"errorMessage":"remote cache directory graph contains a cycle","messagePattern":"remote cache directory graph contains a cycle","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/task/task_cache_store.rs","lineNumber":715,"sourceCode":"        digest: CacheDigest,\n        executable: bool,\n        mode: u32,\n    },\n    Symlink {\n        mode: u32,\n        target: PathBuf,\n    },\n}\n\nasync fn materialize_remote_tree(\n    store: &HttpTaskCacheStore,\n    root: &CacheDigest,\n) -> Result<tempfile::NamedTempFile> {\n    let mut pending = vec![(PathBuf::new(), root.clone(), BTreeSet::new())];\n    let mut nodes = BTreeMap::<PathBuf, RestoredNode>::new();\n    while let Some((path, digest, mut ancestors)) = pending.pop() {\n        if !ancestors.insert(digest.clone()) {\n            bail!(\"remote cache directory graph contains a cycle\");\n        }\n        let bytes = store.client.get_blob(&digest, DIRECTORY_MEDIA_TYPE).await?;\n        let directory: RemoteDirectory = serde_json::from_slice(&bytes)?;\n        if canonical_json(&serde_json::to_value(&directory)?)? != bytes {\n            bail!(\"remote cache directory is not canonical JSON\");\n        }\n        if directory.version != 1 {\n            bail!(\"unsupported remote cache directory version\");\n        }\n        let mut names = BTreeSet::new();\n        for directory in directory.directories {\n            validate_cache_name(&directory.name)?;\n            if !names.insert(directory.name.clone()) {\n                bail!(\"remote cache directory contains duplicate names\");\n            }\n            let child = path.join(&directory.name);\n            validate_cache_path(&child)?;\n            nodes.insert(","sourceCodeStart":697,"sourceCodeEnd":733,"githubUrl":"https://github.com/jdx/mise/blob/533346cc374382b41ec5ff70536252b2e96e725c/src/task/task_cache_store.rs#L697-L733","documentation":"When materializing a remote cache directory tree, the walker tracks the set of digests on the current path (ancestors). If a directory blob's digest is already among its own ancestors, the remote CAS graph is cyclic and would recurse forever, so mise bails. This protects against corrupt or malicious remote data.","triggerScenarios":"A remote directory blob references a child directory whose digest equals an ancestor already on the traversal stack — only possible with a corrupted, hand-edited, or maliciously crafted remote cache entry set.","commonSituations":"Manual tampering with blobs in the remote object store; a buggy custom cache server that computed/reused digests incorrectly; store corruption from partial writes or blob aliasing.","solutions":["Delete the affected cache entry from the remote store and re-run the task to repopulate it.","Verify the remote store's digests against content (each digest must be the hash of its blob) and purge mismatches.","Audit any custom cache-writing tooling for digest computation or deduplication bugs.","Fall back to running the task with cache disabled to unblock local work while the remote entry is fixed."],"exampleFix":null,"handlingStrategy":"validation","validationCode":"function hasCycle(dir, ancestors = new Set()) {\n  if (ancestors.has(dir.digest)) return true;\n  ancestors.add(dir.digest);\n  return dir.childDirs.some(c => hasCycle(c, new Set(ancestors)));\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Never hand-edit remote cache blobs.","Ensure digests are always the hash of the exact blob content.","Periodically audit the remote store for digest/content mismatches."],"tags":["task-cache","remote-cache","cas","cycle-detection"],"backgroundTag":"internal-invariant-violation","analyzedSha":"533346cc374382b41ec5ff70536252b2e96e725c","analyzedAt":"2026-09-17T13:35:38.149Z","contentChangedAt":"2026-09-17T13:35:38.149Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}