{"record":{"id":"ea0a8dafe5f70530","repo":"tobi/qmd","slug":"outside-collection-ea0a8d","errorCode":"OUTSIDE_COLLECTION","errorMessage":"OUTSIDE_COLLECTION","messagePattern":"OUTSIDE_COLLECTION","errorType":"error_code","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"src/store.ts","lineNumber":1655,"sourceCode":"  let indexed = 0, updated = 0, unchanged = 0, processed = 0;\n  const skippedFiles: ReindexSkippedFile[] = [];\n  const seenPaths = new Set<string>();\n  // Literal paths of every file in this scan. Passed to the legacy-path\n  // migration so it never adopts a row that still belongs to a live file.\n  const livePaths = new Set(files.map(f => normalizePathSeparators(f)));\n\n  for (const relativeFile of files) {\n    const filepath = getRealPath(resolve(collectionPath, relativeFile));\n    // Store the literal relative path so the filesystem path can always be\n    // reconstructed as: resolve(collection.path, storedPath).\n    // handelize() is NOT applied at index time — it is display-only.\n    const path = normalizePathSeparators(relativeFile);\n    // Glob `../` segments, absolute patterns, and file symlinks can resolve\n    // outside the collection root. Do not ingest those files, and do not mark\n    // them seen so a previous escaped row is deactivated on this pass.\n    if (!isPathInsideDir(collectionPath, filepath)) {\n      processed++;\n      skippedFiles.push({ file: relativeFile, code: \"OUTSIDE_COLLECTION\" });\n      options?.onProgress?.({ file: relativeFile, current: processed, total });\n      continue;\n    }\n    seenPaths.add(path);\n\n    let content: string;\n    try {\n      content = readFileSync(filepath, \"utf-8\");\n    } catch (err) {\n      // Skip files that can't be read (ETIMEDOUT on APFS compressed files,\n      // EAGAIN on iCloud evicted files, EACCES, etc.) instead of aborting\n      // the rest of the collection (#460).\n      processed++;\n      skippedFiles.push({ file: relativeFile, code: fsErrorCode(err) });\n      options?.onProgress?.({ file: relativeFile, current: processed, total });\n      continue;\n    }\n","sourceCodeStart":1637,"sourceCodeEnd":1673,"githubUrl":"https://github.com/tobi/qmd/blob/dbfd0b4736aeaf761d1a16ca8e424f071df8feb9/src/store.ts#L1637-L1673","documentation":"This error is recorded when a file matched by a collection's glob mask resolves to a location outside the collection root directory. The indexer (src/store.ts) explicitly refuses to ingest such files and does not mark them as seen, so any previously indexed 'escaped' row gets deactivated on the next pass. It is a skip code, not a crash: ingestion continues with remaining files.","triggerScenarios":"Calling the indexing/ingest routine (e.g. via `qmd collection add` or `qmd update`) when: (1) the glob mask contains `../` segments that escape the root, (2) the mask uses an absolute path pattern pointing outside the collection, or (3) a matched path is a symlink whose target lives outside the collection root. The check `!isPathInsideDir(collectionPath, filepath)` then evaluates true and the file is pushed to skippedFiles with code OUTSIDE_COLLECTION.","commonSituations":"Users add a collection with a mask like `**/../notes/*.md`, use an absolute mask (`/etc/**/*.conf`) that is not under the collection directory, or have symlinked folders (dotfiles repos, shared note dirs) whose targets sit elsewhere on disk. Moving or restructuring a collection directory so previously indexed files now resolve outside the root also triggers it on the next update.","solutions":["Fix the glob mask so it only matches paths under the collection root (remove `../` segments and absolute patterns), then run `qmd collection add` again with the corrected `--mask`","If the file legitimately lives outside the root, add the directory it lives in as its own collection instead of trying to reach it with a relative glob","Replace escaping symlinks with real files/directories inside the collection, or move the symlink target under the collection root","If the escape is intentional and trusted, re-scope the collection root one level higher so the target path is inside it"],"exampleFix":"# before\nqmd collection add ~/notes --mask '**/*.md,../journal/*.md'\n\n# after\nqmd collection add ~/notes --mask '**/*.md'\nqmd collection add ~/journal --name journal --mask '**/*.md'","handlingStrategy":"validation","validationCode":"import path from 'node:path';\n\nfunction assertMaskInsideRoot(root: string, mask: string): string[] {\n  const problems: string[] = [];\n  for (const part of mask.split(',')) {\n    const p = part.trim().replace(/^!/, '');\n    if (path.isAbsolute(p)) problems.push(`absolute pattern: ${p}`);\n    if (p.split('/').includes('..')) problems.push(`escaping segment '..': ${p}`);\n  }\n  return problems;\n}\n// run before qmd collection add / update:\nconst issues = assertMaskInsideRoot('/home/me/notes', '**/*.md,../*.md');\nif (issues.length) throw new Error('Mask escapes collection root: ' + issues.join('; '));","typeGuard":"function isSafeMask(mask: string): boolean {\n  return mask.split(',').every(part => {\n    const p = part.trim().replace(/^!/, '');\n    return !path.isAbsolute(p) && !p.split(/[\\\\/]/).includes('..');\n  });\n}","tryCatchPattern":null,"preventionTips":["Always use root-relative mask patterns without '..' segments","Prefer adding another collection over reaching outside the root with globs","Audit symlinks in the collection before running update: find . -type l -exec readlink -f {} \\; and check targets are inside the root","After reorganizing directories, run qmd update and review the 'Skipped file outside collection' warnings"],"tags":["glob","path-traversal","symlink","collection","indexing"],"backgroundTag":"path-outside-root","analyzedSha":"dbfd0b4736aeaf761d1a16ca8e424f071df8feb9","analyzedAt":"2026-08-28T18:07:46.628Z","schemaVersion":2},"datasetVersion":"2026-08-28T21:17:43.275Z"}