diff --git a/README.md b/README.md index f55adff..64259b7 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ These fields can be set in `defaults` and are inherited by every source unless o | `ignoreHidden` | Skip hidden files and directories (dotfiles). Default: `false`. | | `allowHosts` | Allowed Git hosts. Default: `["github.com", "gitlab.com", "visualstudio.com"]`. | | `toc` | Generate per-source `TOC.md`. Default: `true`. Supports `true`, `false`, or a format: `"tree"` (human readable), `"compressed"` | -| `unwrapSingleRootDir` | If the materialized output is nested under a single directory, unwrap it (recursively). Default: `false`. | +| `unwrapSingleRootDir` | If the materialized output is nested under a single directory, unwrap it (recursively). Default: `true`. | ### Source options diff --git a/src/cache/materialize.ts b/src/cache/materialize.ts index fb0b0e2..9e488e6 100644 --- a/src/cache/materialize.ts +++ b/src/cache/materialize.ts @@ -175,7 +175,7 @@ const resolveMaterializeParams = ( ...params, exclude: params.exclude ?? [], ignoreHidden: params.ignoreHidden ?? false, - unwrapSingleRootDir: params.unwrapSingleRootDir ?? false, + unwrapSingleRootDir: params.unwrapSingleRootDir ?? true, json: params.json ?? false, progressThrottleMs: params.progressThrottleMs ?? 120, }); diff --git a/src/config/index.ts b/src/config/index.ts index 5615149..2eb597c 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -39,7 +39,7 @@ export const DEFAULT_CONFIG: DocsCacheConfig = { ignoreHidden: false, allowHosts: ["github.com", "gitlab.com", "visualstudio.com"], toc: true, - unwrapSingleRootDir: false, + unwrapSingleRootDir: true, }, sources: [], } as const; diff --git a/src/source-id.ts b/src/source-id.ts index 2d846c8..987d789 100644 --- a/src/source-id.ts +++ b/src/source-id.ts @@ -1,4 +1,5 @@ -const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; +const INVALID_ID_PATTERN = /[<>:"/\\|?*]/; +const TRAILING_DOT_SPACE_PATTERN = /[.\s]+$/; const MAX_ID_LENGTH = 200; const RESERVED_NAMES = new Set([ ".", @@ -15,15 +16,28 @@ export const assertSafeSourceId = (value: unknown, label: string): string => { if (typeof value !== "string" || value.length === 0) { throw new Error(`${label} must be a non-empty string.`); } + if (value.trim().length === 0) { + throw new Error(`${label} must not be blank.`); + } if (value.length > MAX_ID_LENGTH) { throw new Error(`${label} exceeds maximum length of ${MAX_ID_LENGTH}.`); } - if (!SAFE_ID_PATTERN.test(value)) { + for (const char of value) { + const code = char.codePointAt(0); + if (code !== undefined && (code <= 0x1f || code === 0x7f)) { + throw new Error(`${label} must not contain control characters.`); + } + } + if (TRAILING_DOT_SPACE_PATTERN.test(value)) { + throw new Error(`${label} must not end with dots or spaces.`); + } + if (INVALID_ID_PATTERN.test(value) || value.includes("\0")) { throw new Error( - `${label} must contain only alphanumeric characters, hyphens, and underscores.`, + `${label} must not contain path separators or reserved characters (< > : " / \\ | ? *).`, ); } - if (RESERVED_NAMES.has(value.toUpperCase())) { + const normalized = value.replace(/[.\s]+$/g, ""); + if (RESERVED_NAMES.has(normalized.toUpperCase())) { throw new Error(`${label} uses reserved name '${value}'.`); } return value; diff --git a/tests/edge-cases-security.test.js b/tests/edge-cases-security.test.js index 0397074..45a8cff 100644 --- a/tests/edge-cases-security.test.js +++ b/tests/edge-cases-security.test.js @@ -161,9 +161,7 @@ test("materialize handles deeply nested directories", async () => { // Verify deeply nested file was materialized const { access } = await import("node:fs/promises"); - await access( - path.join(cacheDir, "test", "a", "b", "c", "d", "e", "f", "g", "deep.md"), - ); + await access(path.join(cacheDir, "test", "deep.md")); }); test("materialize handles files with special characters in names", async () => { @@ -406,7 +404,7 @@ test("source ID with null bytes is rejected", async () => { const { loadConfig } = await import("../dist/api.mjs"); await assert.rejects( () => loadConfig(configPath), - /sources\[0\]\.id|alphanumeric/i, + /sources\.0\.id|control characters/i, ); }); diff --git a/tests/edge-cases-validation.test.js b/tests/edge-cases-validation.test.js index a08ed2e..58aeb6b 100644 --- a/tests/edge-cases-validation.test.js +++ b/tests/edge-cases-validation.test.js @@ -36,7 +36,7 @@ test("sourceId with forward slash is rejected", async () => { await assert.rejects( () => loadConfig(configPath), - /sources\[0\]\.id|alphanumeric/i, + /sources\.0\.id|path separators|reserved characters/i, ); }); @@ -47,7 +47,7 @@ test("sourceId with backslash is rejected", async () => { await assert.rejects( () => loadConfig(configPath), - /sources\[0\]\.id|alphanumeric/i, + /sources\.0\.id|path separators|reserved characters/i, ); }); @@ -72,20 +72,43 @@ test("source ID allows hyphen and underscore", async () => { } }); -test("source ID rejects dots and at-signs", async () => { +test("source ID allows dots and at-signs", async () => { const ids = ["test.repo", "test@v1.0", "a.b", "a@b"]; + for (const id of ids) { + const configPath = await writeConfig({ + sources: [{ id, repo: "https://github.com/example/repo.git" }], + }); + const { sources } = await loadConfig(configPath); + assert.equal(sources[0].id, id); + } +}); + +test("source ID rejects trailing dots or spaces", async () => { + const ids = ["repo.", "repo ", "repo..", "repo. "]; + for (const id of ids) { const configPath = await writeConfig({ sources: [{ id, repo: "https://github.com/example/repo.git" }], }); await assert.rejects( () => loadConfig(configPath), - /sources\[0\]\.id|alphanumeric/i, + /sources\.0\.id|dots or spaces/i, ); } }); +test("source ID rejects reserved names", async () => { + const ids = ["CON", "AUX", "NUL", "PRN"]; + + for (const id of ids) { + const configPath = await writeConfig({ + sources: [{ id, repo: "https://github.com/example/repo.git" }], + }); + await assert.rejects(() => loadConfig(configPath), /reserved name/i); + } +}); + test("targetDir with absolute path is rejected", async () => { const configPath = await writeConfig({ sources: [ diff --git a/tests/edge-cases.test.js b/tests/edge-cases.test.js index eddf261..b64520a 100644 --- a/tests/edge-cases.test.js +++ b/tests/edge-cases.test.js @@ -33,7 +33,7 @@ test("config rejects source ID with path traversal characters", async () => { }); await assert.rejects( () => loadConfig(configPath), - /sources\[0\]\.id|alphanumeric/i, + /sources\.0\.id|path separators|reserved characters/i, ); }); @@ -46,7 +46,7 @@ test("config rejects source ID with special characters", async () => { }); await assert.rejects( () => loadConfig(configPath), - /sources\[0\]\.id|alphanumeric/i, + /sources\.0\.id|path separators|reserved characters/i, ); } }); @@ -126,10 +126,7 @@ test("config rejects whitespace-only ID", async () => { const configPath = await writeConfig({ sources: [{ id: " ", repo: "https://github.com/example/repo.git" }], }); - await assert.rejects( - () => loadConfig(configPath), - /sources\[0\]\.id|alphanumeric/i, - ); + await assert.rejects(() => loadConfig(configPath), /sources\.0\.id|blank/i); }); test("config rejects empty repo URL", async () => { diff --git a/tests/sync-include-exclude.test.js b/tests/sync-include-exclude.test.js index 40aadbf..d949ba2 100644 --- a/tests/sync-include-exclude.test.js +++ b/tests/sync-include-exclude.test.js @@ -247,11 +247,8 @@ test("ignoreHidden excludes nested hidden directories", async () => { ); const docsRoot = path.join(cacheDir, "local"); - assert.equal(await exists(path.join(docsRoot, "docs", "guide.md")), true); - assert.equal( - await exists(path.join(docsRoot, "docs", ".git", "config")), - false, - ); + assert.equal(await exists(path.join(docsRoot, "guide.md")), true); + assert.equal(await exists(path.join(docsRoot, ".git", "config")), false); assert.equal( await exists(path.join(docsRoot, "src", ".vscode", "settings.json")), false, @@ -429,7 +426,7 @@ test("sync re-materializes when include rules change", async () => { const docsRoot = path.join(cacheDir, "local"); assert.equal(await exists(path.join(docsRoot, "README.md")), false); - assert.equal(await exists(path.join(docsRoot, "docs", "guide.md")), true); + assert.equal(await exists(path.join(docsRoot, "guide.md")), true); const updatedConfig = { ...baseConfig, @@ -450,5 +447,5 @@ test("sync re-materializes when include rules change", async () => { await runSync(syncOptions, deps); assert.equal(await exists(path.join(docsRoot, "README.md")), true); - assert.equal(await exists(path.join(docsRoot, "docs", "guide.md")), false); + assert.equal(await exists(path.join(docsRoot, "guide.md")), false); }); diff --git a/tests/sync-materialize.test.js b/tests/sync-materialize.test.js index cf235f7..99f7d31 100644 --- a/tests/sync-materialize.test.js +++ b/tests/sync-materialize.test.js @@ -197,12 +197,7 @@ test("sync decodes percent-encoded include patterns", async () => { }, ); - const materializedPath = path.join( - cacheDir, - "local", - decodedDir, - "README.md", - ); + const materializedPath = path.join(cacheDir, "local", "README.md"); assert.equal(await readFile(materializedPath, "utf8"), "hello"); }); @@ -434,7 +429,6 @@ test("sync target can unwrap single root directory", async () => { id: "local", repo: "https://example.com/repo.git", include: ["17/umbraco-forms/**"], - unwrapSingleRootDir: true, }, ], }; @@ -529,6 +523,7 @@ test("sync re-materializes when unwrapSingleRootDir changes", async () => { await writeConfigWithUnwrap(false); await run(); + assert.equal(await exists(path.join(cacheDir, "local", "README.md")), false); assert.equal( await exists( path.join(cacheDir, "local", "17", "umbraco-forms", "README.md"),