Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/cache/materialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
2 changes: 1 addition & 1 deletion src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 6 additions & 3 deletions src/source-id.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
const INVALID_ID_PATTERN = /[<>:"/\\|?*]/;
Comment thread
fbosch marked this conversation as resolved.
const MAX_ID_LENGTH = 200;
const RESERVED_NAMES = new Set([
".",
Expand All @@ -15,12 +15,15 @@ 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)) {
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())) {
Expand Down
6 changes: 2 additions & 4 deletions tests/edge-cases-security.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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|path separators|reserved characters/i,
);
});

Expand Down
12 changes: 5 additions & 7 deletions tests/edge-cases-validation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});

Expand All @@ -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,
);
});

Expand All @@ -72,17 +72,15 @@ 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" }],
});
await assert.rejects(
() => loadConfig(configPath),
/sources\[0\]\.id|alphanumeric/i,
);
const { sources } = await loadConfig(configPath);
assert.equal(sources[0].id, id);
}
});

Expand Down
9 changes: 3 additions & 6 deletions tests/edge-cases.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});

Expand All @@ -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,
);
}
});
Expand Down Expand Up @@ -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 () => {
Expand Down
11 changes: 4 additions & 7 deletions tests/sync-include-exclude.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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);
});
9 changes: 2 additions & 7 deletions tests/sync-materialize.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand Down Expand Up @@ -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,
},
],
};
Expand Down Expand Up @@ -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"),
Expand Down
Loading