{"record":{"id":"55b1207668e4cb2b","repo":"jdx/mise","slug":"brew-cask-appdir-artifact-source-must-stay-be","errorCode":null,"errorMessage":"brew-cask: APPDIR artifact '{source}' must stay below Applications","messagePattern":"brew-cask: APPDIR artifact '(.+?)' must stay below Applications","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/system/packages/brew/cask.rs","lineNumber":4253,"sourceCode":"        return Ok(source);\n    }\n    Err(eyre!(\n        \"brew-cask: completion executable '{}' was not found\",\n        executable\n    ))\n}\n\nfn appdir_artifact_source(source: &str, apps: &[AppArtifact]) -> Result<Option<PathBuf>> {\n    let Some(relative) = source.strip_prefix(\"$APPDIR/\") else {\n        return Ok(None);\n    };\n    let relative = Path::new(relative);\n    if relative.components().next().is_none()\n        || relative\n            .components()\n            .any(|component| !matches!(component, Component::Normal(_)))\n    {\n        bail!(\"brew-cask: APPDIR artifact '{source}' must stay below Applications\");\n    }\n    let Some(Component::Normal(bundle)) = relative.components().next() else {\n        return Ok(None);\n    };\n    let suffix = relative.components().skip(1).collect::<PathBuf>();\n    let mut matches = Vec::new();\n    for app in apps {\n        let target = app_target_path(app.target_name())?;\n        let bundle = Path::new(bundle);\n        if !path_ends_with_ignore_ascii_case(Path::new(&app.source), bundle)\n            && !path_ends_with_ignore_ascii_case(&target, bundle)\n        {\n            continue;\n        }\n        let path = target.join(&suffix);\n        if path.is_file() {\n            matches.push(path);\n        }","sourceCodeStart":4235,"sourceCodeEnd":4271,"githubUrl":"https://github.com/jdx/mise/blob/6f52dcdf99e282ef7a7db68c81301fa4618d0f79/src/system/packages/brew/cask.rs#L4235-L4271","documentation":"A `$APPDIR/...` artifact source must be a plain relative path directly beneath Applications: after stripping the `$APPDIR/` prefix, the remainder must be non-empty and consist solely of normal components (no `..`, no absolute pieces). The first component names an app bundle to match against; anything else is rejected before matching begins.","triggerScenarios":"Sources like `$APPDIR/../X`, `$APPDIR/` (empty remainder) or `$APPDIR//x`; templating that concatenates an empty or parent-referring segment into the source string.","commonSituations":"Hand-edited cask stanzas; template bugs producing `$APPDIR/{}`; ported ruby stanzas that used absolute /Applications paths with parent references.","solutions":["Rewrite the source as `$APPDIR/Bundle.app/...` using only normal path components","Validate template expansion before it reaches the artifact definition","For locations outside Applications, use the artifact forms and bases that support them instead of `$APPDIR`"],"exampleFix":"# before\ncompletion(source: \"$APPDIR/../Shared/_myapp\", ...)\n# after\ncompletion(source: \"$APPDIR/MyApp.app/Contents/Resources/_myapp\", ...)","handlingStrategy":"type-guard","validationCode":null,"typeGuard":"// $APPDIR sources must be plain relative paths under Applications\nfn is_valid_appdir_source(source: &str) -> bool {\n    let Some(rel) = source.strip_prefix(\"$APPDIR/\") else {\n        return true; // not an appdir source; other rules apply\n    };\n    let p = std::path::Path::new(rel);\n    p.components().next().is_some()\n        && p.components().all(|c| matches!(c, std::path::Component::Normal(_)))\n}","tryCatchPattern":"if err.to_string().contains(\"must stay below Applications\") {\n    // rewrite as $APPDIR/Bundle.app/... with normal components only\n}","preventionTips":["Always start $APPDIR sources with a concrete bundle name","Lint generated stanzas for `..`, empty segments, and doubled slashes after $APPDIR/","Use the appropriate base for paths outside Applications"],"tags":["brew-cask","appdir","path-validation","install-artifact"],"backgroundTag":"path-traversal-detected","analyzedSha":"6f52dcdf99e282ef7a7db68c81301fa4618d0f79","analyzedAt":"2026-08-22T10:14:23.840Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}