Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
22 changes: 18 additions & 4 deletions src/source-id.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
const INVALID_ID_PATTERN = /[<>:"/\\|?*]/;
Comment thread
fbosch marked this conversation as resolved.
const TRAILING_DOT_SPACE_PATTERN = /[.\s]+$/;
const MAX_ID_LENGTH = 200;
const RESERVED_NAMES = new Set([
".",
Expand All @@ -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;
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|control characters/i,
);
});

Expand Down
31 changes: 27 additions & 4 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,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: [
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