Skip to content

Commit df883dc

Browse files
authored
feat(config): enable unwrapSingleRootDir by default (#23)
* feat(config): set unwrapSingleRootDir to true * feat(source-id): update ID validation rules * fix: valid paths
1 parent 9bf17c1 commit df883dc

9 files changed

Lines changed: 59 additions & 35 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ These fields can be set in `defaults` and are inherited by every source unless o
102102
| `ignoreHidden` | Skip hidden files and directories (dotfiles). Default: `false`. |
103103
| `allowHosts` | Allowed Git hosts. Default: `["github.com", "gitlab.com", "visualstudio.com"]`. |
104104
| `toc` | Generate per-source `TOC.md`. Default: `true`. Supports `true`, `false`, or a format: `"tree"` (human readable), `"compressed"` |
105-
| `unwrapSingleRootDir` | If the materialized output is nested under a single directory, unwrap it (recursively). Default: `false`. |
105+
| `unwrapSingleRootDir` | If the materialized output is nested under a single directory, unwrap it (recursively). Default: `true`. |
106106

107107
### Source options
108108

src/cache/materialize.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ const resolveMaterializeParams = (
175175
...params,
176176
exclude: params.exclude ?? [],
177177
ignoreHidden: params.ignoreHidden ?? false,
178-
unwrapSingleRootDir: params.unwrapSingleRootDir ?? false,
178+
unwrapSingleRootDir: params.unwrapSingleRootDir ?? true,
179179
json: params.json ?? false,
180180
progressThrottleMs: params.progressThrottleMs ?? 120,
181181
});

src/config/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export const DEFAULT_CONFIG: DocsCacheConfig = {
3939
ignoreHidden: false,
4040
allowHosts: ["github.com", "gitlab.com", "visualstudio.com"],
4141
toc: true,
42-
unwrapSingleRootDir: false,
42+
unwrapSingleRootDir: true,
4343
},
4444
sources: [],
4545
} as const;

src/source-id.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
1+
const INVALID_ID_PATTERN = /[<>:"/\\|?*]/;
2+
const TRAILING_DOT_SPACE_PATTERN = /[.\s]+$/;
23
const MAX_ID_LENGTH = 200;
34
const RESERVED_NAMES = new Set([
45
".",
@@ -15,15 +16,28 @@ export const assertSafeSourceId = (value: unknown, label: string): string => {
1516
if (typeof value !== "string" || value.length === 0) {
1617
throw new Error(`${label} must be a non-empty string.`);
1718
}
19+
if (value.trim().length === 0) {
20+
throw new Error(`${label} must not be blank.`);
21+
}
1822
if (value.length > MAX_ID_LENGTH) {
1923
throw new Error(`${label} exceeds maximum length of ${MAX_ID_LENGTH}.`);
2024
}
21-
if (!SAFE_ID_PATTERN.test(value)) {
25+
for (const char of value) {
26+
const code = char.codePointAt(0);
27+
if (code !== undefined && (code <= 0x1f || code === 0x7f)) {
28+
throw new Error(`${label} must not contain control characters.`);
29+
}
30+
}
31+
if (TRAILING_DOT_SPACE_PATTERN.test(value)) {
32+
throw new Error(`${label} must not end with dots or spaces.`);
33+
}
34+
if (INVALID_ID_PATTERN.test(value) || value.includes("\0")) {
2235
throw new Error(
23-
`${label} must contain only alphanumeric characters, hyphens, and underscores.`,
36+
`${label} must not contain path separators or reserved characters (< > : " / \\ | ? *).`,
2437
);
2538
}
26-
if (RESERVED_NAMES.has(value.toUpperCase())) {
39+
const normalized = value.replace(/[.\s]+$/g, "");
40+
if (RESERVED_NAMES.has(normalized.toUpperCase())) {
2741
throw new Error(`${label} uses reserved name '${value}'.`);
2842
}
2943
return value;

tests/edge-cases-security.test.js

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,7 @@ test("materialize handles deeply nested directories", async () => {
161161

162162
// Verify deeply nested file was materialized
163163
const { access } = await import("node:fs/promises");
164-
await access(
165-
path.join(cacheDir, "test", "a", "b", "c", "d", "e", "f", "g", "deep.md"),
166-
);
164+
await access(path.join(cacheDir, "test", "deep.md"));
167165
});
168166

169167
test("materialize handles files with special characters in names", async () => {
@@ -406,7 +404,7 @@ test("source ID with null bytes is rejected", async () => {
406404
const { loadConfig } = await import("../dist/api.mjs");
407405
await assert.rejects(
408406
() => loadConfig(configPath),
409-
/sources\[0\]\.id|alphanumeric/i,
407+
/sources\.0\.id|control characters/i,
410408
);
411409
});
412410

tests/edge-cases-validation.test.js

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ test("sourceId with forward slash is rejected", async () => {
3636

3737
await assert.rejects(
3838
() => loadConfig(configPath),
39-
/sources\[0\]\.id|alphanumeric/i,
39+
/sources\.0\.id|path separators|reserved characters/i,
4040
);
4141
});
4242

@@ -47,7 +47,7 @@ test("sourceId with backslash is rejected", async () => {
4747

4848
await assert.rejects(
4949
() => loadConfig(configPath),
50-
/sources\[0\]\.id|alphanumeric/i,
50+
/sources\.0\.id|path separators|reserved characters/i,
5151
);
5252
});
5353

@@ -72,20 +72,43 @@ test("source ID allows hyphen and underscore", async () => {
7272
}
7373
});
7474

75-
test("source ID rejects dots and at-signs", async () => {
75+
test("source ID allows dots and at-signs", async () => {
7676
const ids = ["test.repo", "test@v1.0", "a.b", "a@b"];
7777

78+
for (const id of ids) {
79+
const configPath = await writeConfig({
80+
sources: [{ id, repo: "https://github.com/example/repo.git" }],
81+
});
82+
const { sources } = await loadConfig(configPath);
83+
assert.equal(sources[0].id, id);
84+
}
85+
});
86+
87+
test("source ID rejects trailing dots or spaces", async () => {
88+
const ids = ["repo.", "repo ", "repo..", "repo. "];
89+
7890
for (const id of ids) {
7991
const configPath = await writeConfig({
8092
sources: [{ id, repo: "https://github.com/example/repo.git" }],
8193
});
8294
await assert.rejects(
8395
() => loadConfig(configPath),
84-
/sources\[0\]\.id|alphanumeric/i,
96+
/sources\.0\.id|dots or spaces/i,
8597
);
8698
}
8799
});
88100

101+
test("source ID rejects reserved names", async () => {
102+
const ids = ["CON", "AUX", "NUL", "PRN"];
103+
104+
for (const id of ids) {
105+
const configPath = await writeConfig({
106+
sources: [{ id, repo: "https://github.com/example/repo.git" }],
107+
});
108+
await assert.rejects(() => loadConfig(configPath), /reserved name/i);
109+
}
110+
});
111+
89112
test("targetDir with absolute path is rejected", async () => {
90113
const configPath = await writeConfig({
91114
sources: [

tests/edge-cases.test.js

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ test("config rejects source ID with path traversal characters", async () => {
3333
});
3434
await assert.rejects(
3535
() => loadConfig(configPath),
36-
/sources\[0\]\.id|alphanumeric/i,
36+
/sources\.0\.id|path separators|reserved characters/i,
3737
);
3838
});
3939

@@ -46,7 +46,7 @@ test("config rejects source ID with special characters", async () => {
4646
});
4747
await assert.rejects(
4848
() => loadConfig(configPath),
49-
/sources\[0\]\.id|alphanumeric/i,
49+
/sources\.0\.id|path separators|reserved characters/i,
5050
);
5151
}
5252
});
@@ -126,10 +126,7 @@ test("config rejects whitespace-only ID", async () => {
126126
const configPath = await writeConfig({
127127
sources: [{ id: " ", repo: "https://github.com/example/repo.git" }],
128128
});
129-
await assert.rejects(
130-
() => loadConfig(configPath),
131-
/sources\[0\]\.id|alphanumeric/i,
132-
);
129+
await assert.rejects(() => loadConfig(configPath), /sources\.0\.id|blank/i);
133130
});
134131

135132
test("config rejects empty repo URL", async () => {

tests/sync-include-exclude.test.js

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -247,11 +247,8 @@ test("ignoreHidden excludes nested hidden directories", async () => {
247247
);
248248

249249
const docsRoot = path.join(cacheDir, "local");
250-
assert.equal(await exists(path.join(docsRoot, "docs", "guide.md")), true);
251-
assert.equal(
252-
await exists(path.join(docsRoot, "docs", ".git", "config")),
253-
false,
254-
);
250+
assert.equal(await exists(path.join(docsRoot, "guide.md")), true);
251+
assert.equal(await exists(path.join(docsRoot, ".git", "config")), false);
255252
assert.equal(
256253
await exists(path.join(docsRoot, "src", ".vscode", "settings.json")),
257254
false,
@@ -429,7 +426,7 @@ test("sync re-materializes when include rules change", async () => {
429426

430427
const docsRoot = path.join(cacheDir, "local");
431428
assert.equal(await exists(path.join(docsRoot, "README.md")), false);
432-
assert.equal(await exists(path.join(docsRoot, "docs", "guide.md")), true);
429+
assert.equal(await exists(path.join(docsRoot, "guide.md")), true);
433430

434431
const updatedConfig = {
435432
...baseConfig,
@@ -450,5 +447,5 @@ test("sync re-materializes when include rules change", async () => {
450447
await runSync(syncOptions, deps);
451448

452449
assert.equal(await exists(path.join(docsRoot, "README.md")), true);
453-
assert.equal(await exists(path.join(docsRoot, "docs", "guide.md")), false);
450+
assert.equal(await exists(path.join(docsRoot, "guide.md")), false);
454451
});

tests/sync-materialize.test.js

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -197,12 +197,7 @@ test("sync decodes percent-encoded include patterns", async () => {
197197
},
198198
);
199199

200-
const materializedPath = path.join(
201-
cacheDir,
202-
"local",
203-
decodedDir,
204-
"README.md",
205-
);
200+
const materializedPath = path.join(cacheDir, "local", "README.md");
206201
assert.equal(await readFile(materializedPath, "utf8"), "hello");
207202
});
208203

@@ -434,7 +429,6 @@ test("sync target can unwrap single root directory", async () => {
434429
id: "local",
435430
repo: "https://example.com/repo.git",
436431
include: ["17/umbraco-forms/**"],
437-
unwrapSingleRootDir: true,
438432
},
439433
],
440434
};
@@ -529,6 +523,7 @@ test("sync re-materializes when unwrapSingleRootDir changes", async () => {
529523

530524
await writeConfigWithUnwrap(false);
531525
await run();
526+
assert.equal(await exists(path.join(cacheDir, "local", "README.md")), false);
532527
assert.equal(
533528
await exists(
534529
path.join(cacheDir, "local", "17", "umbraco-forms", "README.md"),

0 commit comments

Comments
 (0)