{"record":{"id":"74b40192f23294c3","repo":"abhigyanpatwari/GitNexus","slug":"refusing-to-adopt-branch-metadata-branch-storage-target","errorCode":null,"errorMessage":"Refusing to adopt branch metadata: branch storage target escapes branches/.","messagePattern":"Refusing to adopt branch metadata: branch storage target escapes branches/\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"gitnexus/src/storage/repo-manager.ts","lineNumber":1310,"sourceCode":"    } else {\n      const probeCode = await fs.access(branchDir).then(\n        () => null,\n        (e: unknown) => (e as NodeJS.ErrnoException)?.code ?? 'UNKNOWN',\n      );\n      dirGone = probeCode === 'ENOENT' || probeCode === 'ENOTDIR';\n    }\n    if (dirGone) {\n      // Non-recursive by design: only removes the parent when no other pinned\n      // sub-index remains, so an empty branches/ dir doesn't read as \"pinned\".\n      await fs.rmdir(path.join(storagePath, BRANCHES_DIR)).catch(() => {});\n    } else {\n      logger.warn(\n        { path: branchDir, code: rmError?.code },\n        'Could not remove the shadowed branch sub-index; keeping its registry summary so `gitnexus clean --branch` can still target it.',\n      );\n    }\n  } else {\n    throw new Error('Refusing to adopt branch metadata: branch storage target escapes branches/.');\n  }\n\n  // Re-read AFTER the potentially slow recursive rm, and under the lock: the\n  // registry is a multi-writer whole-file overwrite, and writing a pre-rm\n  // snapshot would silently clobber concurrent registerRepo/removeBranchIndex\n  // writers — the #2106 R9 re-read-before-write discipline registerRepo follows.\n  await withRegistryLock(async () => {\n    const entries = await readRegistry();\n    const idx = isRegistered(entries);\n    if (idx < 0) return; // unregistered concurrently → still a no-op\n    const entry = entries[idx];\n    if (!registryPathEquals(canonicalizePath(entry.storagePath), canonicalizePath(storagePath))) {\n      return; // a concurrent registration selected a different slot\n    }\n    const remaining = dirGone ? entry.branches?.filter((b) => b.branch !== branch) : entry.branches;\n    const droppedSummary = (entry.branches?.length ?? 0) !== (remaining?.length ?? 0);\n    if (entry.branch === branch && !droppedSummary) return; // already coherent\n    entry.branch = branch;","sourceCodeStart":1292,"sourceCodeEnd":1328,"githubUrl":"https://github.com/abhigyanpatwari/GitNexus/blob/ac9a4e9abd8fd3058c070b72c23402a4f887929a/gitnexus/src/storage/repo-manager.ts#L1292-L1328","documentation":"Branch storage targets must live under .gitnexus/branches/ so `gitnexus clean --branch` can always locate and remove them (containment guard). When the computed branch directory for the branch index falls outside that subtree, adoption is refused rather than creating an unmanageable orphan index.","triggerScenarios":"registerRepo branch adoption computes a branchDir from the branch name/storage path that does not resolve to a path under .gitnexus/branches/ — typically due to path-segment injection via a crafted branch name or a misconfigured storage root.","commonSituations":"Branch names containing path separators or '..' segments reaching the storage-layer naming logic; a custom storage configuration whose branches/ root resolves outside the repo's .gitnexus directory; symlinked storage paths resolving outside the expected subtree.","solutions":["Use a branch name without slashes, '..' or other path-special characters, or sanitize it before invoking the API.","Ensure the storage configuration keeps branches under <repo>/.gitnexus/branches/.","If the branch name legitimately contains '/', adopt with its normalized/sanitized form that the storage layer expects.","Check for symlinked storage directories resolving outside .gitnexus and flatten them."],"exampleFix":"// before\nawait adoptBranch(repo, \"../../evil\");\n// after\nawait adoptBranch(repo, \"evil\"); // sanitized, resolves under .gitnexus/branches/","handlingStrategy":"validation","validationCode":"const branchDir = path.resolve(storageRoot, \"branches\", sanitizeBranchName(branch));\nconst allowedRoot = path.resolve(storageRoot, \"branches\") + path.sep;\nif (!branchDir.startsWith(allowedRoot)) {\n  throw new Error(`branch storage target ${branchDir} escapes branches/`);\n}","typeGuard":null,"tryCatchPattern":"try {\n  await adoptBranchMetadata(repo, branch);\n} catch (err) {\n  if (/escapes branches\\//.test(String(err))) {\n    const safe = branch.replace(/[^A-Za-z0-9._-]+/g, \"-\");\n    await adoptBranchMetadata(repo, safe);\n  } else throw err;\n}","preventionTips":["Sanitize branch names (strip '/', '..') before passing them to storage APIs.","Keep branch storage under the repo's .gitnexus/branches/ directory.","Avoid symlinked storage roots that resolve outside .gitnexus.","Never feed user-controlled branch names to storage paths unsanitized."],"tags":["path-traversal","branch","storage","security"],"backgroundTag":"path-traversal-blocked","analyzedSha":"ac9a4e9abd8fd3058c070b72c23402a4f887929a","analyzedAt":"2026-09-15T23:29:44.066Z","contentChangedAt":"2026-09-15T23:29:44.066Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}