@readme/cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
 
4
4
  import { CORE_SCHEMA, load as loadYAML, mergeTag } from 'js-yaml';
5
5
 
6
+ import { makeContainment, PathEscapeError } from '../utils/containment.js';
6
7
  import { writeGithubActionsOutputs } from '../utils/gha-output.js';
7
8
  import { parseFrontmatter, toFrontmatter, frontmatterWriteError } from '../utils/markdown.js';
8
9
  import * as styles from '../utils/styles.js';
@@ -22,20 +23,23 @@ const OAS_YAML_SCHEMA = CORE_SCHEMA.withTags(mergeTag);
22
23
  * Find OAS files at the root of reference/ (JSON or YAML).
23
24
  */
24
25
  export function findOasFiles(refDir) {
25
- const entries = fs.readdirSync(refDir);
26
+ const entries = fs.readdirSync(refDir, { withFileTypes: true });
26
27
  const oasFiles = [];
27
28
 
28
29
  for (const entry of entries) {
29
- if (!/\.(json|yaml|yml)$/i.test(entry)) continue;
30
- const filePath = path.join(refDir, entry);
31
- if (!fs.statSync(filePath).isFile()) continue;
30
+ // `stat` follows links. A spec committed as a symlink would be read from
31
+ // outside the repo; skip it. `syncOas` refuses the link itself, including
32
+ // when it is the only spec, so this skip cannot turn into a silent success.
33
+ if (entry.isSymbolicLink() || !entry.isFile()) continue;
34
+ if (!/\.(json|yaml|yml)$/i.test(entry.name)) continue;
35
+ const filePath = path.join(refDir, entry.name);
32
36
 
33
37
  try {
34
38
  const raw = fs.readFileSync(filePath, 'utf-8');
35
- const parsed = entry.endsWith('.json') ? JSON.parse(raw) : loadYAML(raw, { schema: OAS_YAML_SCHEMA });
39
+ const parsed = entry.name.endsWith('.json') ? JSON.parse(raw) : loadYAML(raw, { schema: OAS_YAML_SCHEMA });
36
40
 
37
41
  if (parsed && (parsed.openapi || parsed.swagger)) {
38
- oasFiles.push({ filename: entry, spec: parsed });
42
+ oasFiles.push({ filename: entry.name, spec: parsed });
39
43
  }
40
44
  } catch {
41
45
  // Skip files that can't be parsed.
@@ -227,12 +231,14 @@ export function collectExistingPages(refDir) {
227
231
  const pages = [];
228
232
 
229
233
  function walk(dir) {
230
- for (const entry of fs.readdirSync(dir)) {
231
- const full = path.join(dir, entry);
232
- const stat = fs.statSync(full);
233
- if (stat.isDirectory()) {
234
+ // Dirent, not `stat`: `stat` follows symlinks, so a directory link
235
+ // committed under reference/ would be walked and its pages later unlinked.
236
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
237
+ if (entry.isSymbolicLink()) continue;
238
+ const full = path.join(dir, entry.name);
239
+ if (entry.isDirectory()) {
234
240
  walk(full);
235
- } else if (entry.endsWith('.md')) {
241
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
236
242
  const content = fs.readFileSync(full, 'utf-8');
237
243
  const { data } = parseFrontmatter(content);
238
244
  if (data.api && data.api.file && data.api.operationId) {
@@ -270,31 +276,45 @@ function parseOrderYaml(content) {
270
276
  );
271
277
  }
272
278
 
273
- function writeOrderYaml(filePath, slugs) {
279
+ function writeOrderYaml(filePath, slugs, assertContained) {
280
+ assertContained(filePath);
274
281
  const content = `${slugs.map(s => `- ${yamlSafeSlug(s)}`).join('\n')}\n`;
275
282
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
276
283
  fs.writeFileSync(filePath, content);
277
284
  }
278
285
 
279
- function addToOrder(orderPath, slug) {
280
- if (fs.existsSync(orderPath)) {
281
- const content = fs.readFileSync(orderPath, 'utf-8');
282
- const slugs = parseOrderYaml(content);
283
- if (!slugs.includes(slug)) {
284
- slugs.push(slug);
285
- writeOrderYaml(orderPath, slugs);
286
- }
287
- } else {
288
- writeOrderYaml(orderPath, [slug]);
286
+ // `assertContained` allows a path that does not exist yet, so lstat first to
287
+ // tell an absent order file from one that must be refused before it is read.
288
+ function readOrderIfExists(orderPath, assertContained) {
289
+ try {
290
+ fs.lstatSync(orderPath);
291
+ } catch (err) {
292
+ if (err.code === 'ENOENT') return null;
293
+ throw err;
289
294
  }
295
+ assertContained(orderPath);
296
+ return fs.readFileSync(orderPath, 'utf-8');
290
297
  }
291
298
 
292
- function removeFromOrder(orderPath, slug) {
293
- if (!fs.existsSync(orderPath)) return;
294
- const content = fs.readFileSync(orderPath, 'utf-8');
299
+ function addToOrder(orderPath, slug, assertContained) {
300
+ const content = readOrderIfExists(orderPath, assertContained);
301
+ if (content === null) {
302
+ writeOrderYaml(orderPath, [slug], assertContained);
303
+ return;
304
+ }
305
+ const slugs = parseOrderYaml(content);
306
+ if (!slugs.includes(slug)) {
307
+ slugs.push(slug);
308
+ writeOrderYaml(orderPath, slugs, assertContained);
309
+ }
310
+ }
311
+
312
+ function removeFromOrder(orderPath, slug, assertContained) {
313
+ const content = readOrderIfExists(orderPath, assertContained);
314
+ if (content === null) return;
295
315
  const slugs = parseOrderYaml(content).filter(s => s !== slug);
296
316
  if (slugs.length > 0) {
297
- writeOrderYaml(orderPath, slugs);
317
+ writeOrderYaml(orderPath, slugs, assertContained);
298
318
  } else {
299
319
  fs.unlinkSync(orderPath);
300
320
  }
@@ -310,11 +330,35 @@ function safeSegment(value, fallback) {
310
330
  return !segment || segment === '.' || segment === '..' ? fallback : segment;
311
331
  }
312
332
 
333
+ /**
334
+ * Lexical "is `target` inside `baseDir`?" for placement decisions about
335
+ * pages the walk already found without following links. Not a write guard —
336
+ * writes go through `assertContained`, which refuses symlinks. `path.relative`
337
+ * alone treats a symlink under reference/ as inside the repo.
338
+ */
313
339
  function isWithin(baseDir, target) {
314
340
  const rel = path.relative(baseDir, target);
315
341
  return rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
316
342
  }
317
343
 
344
+ /**
345
+ * Throw if any symlink exists under `refDir`. Run before the first mutation:
346
+ * a link discovered halfway through the sync would otherwise leave the
347
+ * in-repo half of the run applied. Does not follow links.
348
+ */
349
+ function refuseSymlinks(refDir) {
350
+ const walk = dir => {
351
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
352
+ const full = path.join(dir, entry.name);
353
+ if (entry.isSymbolicLink()) {
354
+ throw new PathEscapeError(full, 'symbolic links inside the reference directory are not followed');
355
+ }
356
+ if (entry.isDirectory()) walk(full);
357
+ }
358
+ };
359
+ walk(refDir);
360
+ }
361
+
318
362
  function buildPageContent({ oasFilename, operationId, isWebhook, hidden = false }) {
319
363
  const frontmatter = {
320
364
  api: {
@@ -396,13 +440,16 @@ function operationGroup(op) {
396
440
  */
397
441
  function existingFoldersBySlug(apiDir) {
398
442
  const bySlug = new Map();
399
- let entries;
400
443
  try {
401
- entries = fs.readdirSync(apiDir, { withFileTypes: true });
402
- } catch {
403
- return bySlug; // apiDir doesn't exist yet — nothing to reuse.
444
+ // Caller has already refused a symlink at apiDir. lstat only distinguishes
445
+ // "not created yet" from a real directory `readdir` can list.
446
+ fs.lstatSync(apiDir);
447
+ } catch (err) {
448
+ if (err.code === 'ENOENT') return bySlug; // apiDir doesn't exist yet — nothing to reuse.
449
+ throw err;
404
450
  }
405
- for (const entry of entries) {
451
+
452
+ for (const entry of fs.readdirSync(apiDir, { withFileTypes: true })) {
406
453
  if (entry.isDirectory()) bySlug.set(slugifyFolder(entry.name), entry.name);
407
454
  }
408
455
  return bySlug;
@@ -426,20 +473,16 @@ function collectReferenceSlugs(refDir) {
426
473
  const counts = new Map();
427
474
 
428
475
  function walk(dir) {
429
- for (const entry of fs.readdirSync(dir)) {
430
- const full = path.join(dir, entry);
431
- let stat;
432
- try {
433
- stat = fs.statSync(full);
434
- } catch {
435
- continue;
436
- }
437
- if (stat.isDirectory()) {
476
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
477
+ if (entry.isSymbolicLink()) continue;
478
+
479
+ const full = path.join(dir, entry.name);
480
+ if (entry.isDirectory()) {
438
481
  walk(full);
439
- } else if (entry.endsWith('.md')) {
482
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
440
483
  // A folder's index.md contributes the folder name as a slug; any other
441
484
  // page contributes its own filename.
442
- const slug = entry === 'index.md' ? path.basename(dir) : path.basename(entry, '.md');
485
+ const slug = entry.name === 'index.md' ? path.basename(dir) : path.basename(entry.name, '.md');
443
486
  takeSlug(counts, slug);
444
487
  }
445
488
  }
@@ -489,7 +532,8 @@ function reserveSlug(takenSlugs, base) {
489
532
  * a copy of the parsed frontmatter and edits it. Returns true if the file
490
533
  * changed.
491
534
  */
492
- function updateFrontmatter(filePath, mutate) {
535
+ function updateFrontmatter(filePath, mutate, assertContained) {
536
+ assertContained(filePath);
493
537
  const content = fs.readFileSync(filePath, 'utf-8');
494
538
  const parsed = parseFrontmatter(content);
495
539
  // Refuse to rewrite from empty `data` when YAML failed to parse.
@@ -498,6 +542,7 @@ function updateFrontmatter(filePath, mutate) {
498
542
  mutate(data);
499
543
  const next = parsed.content.trim() ? toFrontmatter(data, parsed.content) : toFrontmatter(data);
500
544
  if (next === content || JSON.stringify(data) === JSON.stringify(parsed.data)) return false;
545
+ assertContained(filePath);
501
546
  fs.writeFileSync(filePath, next);
502
547
  return true;
503
548
  }
@@ -574,23 +619,32 @@ function isGeneratedTagIndex(indexPath, specDescriptions) {
574
619
  * by flattening `tag/index.md` into a sibling `tag.md` (same slug, so the
575
620
  * parent `_order.yaml` entry still applies).
576
621
  */
577
- function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }) {
578
- let entries;
622
+ function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes, assertContained }) {
623
+ assertContained(dir);
579
624
  try {
580
- entries = fs.readdirSync(dir);
581
- } catch {
582
- return;
625
+ fs.lstatSync(dir);
626
+ } catch (err) {
627
+ if (err.code === 'ENOENT') return;
628
+ throw err;
629
+ }
630
+
631
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
632
+ for (const entry of entries) {
633
+ assertContained(path.join(dir, entry.name));
583
634
  }
584
- const others = entries.filter(e => !INDEX_FILES.includes(e) && e !== '_order.yaml');
635
+
636
+ const names = entries.map(e => e.name);
637
+ const others = names.filter(e => !INDEX_FILES.includes(e) && e !== '_order.yaml');
585
638
  if (others.length) return;
586
639
 
587
640
  const parentDir = path.dirname(dir);
588
641
  const slug = path.basename(dir);
589
- const indexFile = entries.find(e => INDEX_FILES.includes(e));
642
+ const indexFile = names.find(e => INDEX_FILES.includes(e));
590
643
 
591
644
  if (!indexFile || isGeneratedTagIndex(path.join(dir, indexFile), specDescriptions)) {
645
+ assertContained(dir);
592
646
  fs.rmSync(dir, { recursive: true, force: true });
593
- removeFromOrder(path.join(parentDir, '_order.yaml'), slug);
647
+ removeFromOrder(path.join(parentDir, '_order.yaml'), slug, assertContained);
594
648
  if (indexFile) {
595
649
  releaseSlug(takenSlugs, slug);
596
650
  changes.deleted.push(path.relative(refDir, path.join(dir, indexFile)));
@@ -600,8 +654,11 @@ function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }
600
654
 
601
655
  const from = path.join(dir, indexFile);
602
656
  const to = path.join(parentDir, `${slug}${path.extname(indexFile)}`);
657
+ assertContained(from);
658
+ assertContained(to);
603
659
  if (fs.existsSync(to)) return;
604
660
  fs.renameSync(from, to);
661
+ assertContained(dir);
605
662
  fs.rmSync(dir, { recursive: true, force: true });
606
663
  changes.moved.push({ from: path.relative(refDir, from), to: path.relative(refDir, to) });
607
664
  }
@@ -612,7 +669,7 @@ function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }
612
669
  * `takenSlugs` is the reference-wide set of slugs already in use; it is read and
613
670
  * mutated so slugs stay unique across every spec processed in one sync run.
614
671
  */
615
- function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
672
+ function syncOneOas(refDir, oasFilename, spec, takenSlugs, assertContained) {
616
673
  const specOps = extractOperations(spec);
617
674
  const infoTitle = safeSegment(spec.info?.title || path.basename(oasFilename, path.extname(oasFilename)), 'api');
618
675
 
@@ -623,6 +680,10 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
623
680
  // folder for this API as an O(1) lookup, rather than walking apiDir again
624
681
  // for each distinct tag/group.
625
682
  const apiDir = path.join(refDir, infoTitle);
683
+
684
+ // Before any read of the target or any delete. A title directory committed
685
+ // as a symlink would otherwise be readdir'd (and later written) through.
686
+ assertContained(apiDir);
626
687
  const foldersBySlug = existingFoldersBySlug(apiDir);
627
688
 
628
689
  const existingPages = collectExistingPages(refDir).filter(p => p.data.api.file === oasFilename);
@@ -651,6 +712,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
651
712
  // Deletes: pages referencing operations that no longer exist.
652
713
  for (const [opId, page] of pagesByOpId) {
653
714
  if (!specOps.has(opId)) {
715
+ assertContained(page.filePath);
654
716
  fs.unlinkSync(page.filePath);
655
717
 
656
718
  const pageDir = path.dirname(page.filePath);
@@ -665,7 +727,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
665
727
  const isIndexPage = path.basename(page.filePath) === 'index.md';
666
728
  const pageSlug = path.basename(page.filePath, '.md');
667
729
  const referenceSlug = isIndexPage ? path.basename(pageDir) : pageSlug;
668
- removeFromOrder(path.join(pageDir, '_order.yaml'), pageSlug);
730
+ removeFromOrder(path.join(pageDir, '_order.yaml'), pageSlug, assertContained);
669
731
  releaseSlug(takenSlugs, referenceSlug);
670
732
 
671
733
  changes.deleted.push(page.relativePath);
@@ -727,7 +789,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
727
789
  const { title, description } = groupsByFolder.get(folder);
728
790
  const actualFolder = foldersBySlug.get(folder) || folder;
729
791
  const pageDir = path.join(refDir, infoTitle, actualFolder);
730
- if (!isWithin(refDir, pageDir)) continue;
792
+ assertContained(pageDir);
731
793
 
732
794
  const indexPath = path.join(pageDir, 'index.md');
733
795
  if (!populatedFolders.has(folder) && !fs.existsSync(indexPath)) continue;
@@ -735,6 +797,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
735
797
 
736
798
  if (!fs.existsSync(indexPath)) {
737
799
  // Never overwrite an existing index.md — it may be a hand-written category.
800
+ assertContained(indexPath);
738
801
  fs.mkdirSync(pageDir, { recursive: true });
739
802
  fs.writeFileSync(indexPath, buildTagIndexContent(title, description));
740
803
  changes.added.push(path.relative(refDir, indexPath));
@@ -747,15 +810,19 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
747
810
  // With `apply-tag-changes`, the spec owns the category page's title and
748
811
  // excerpt too (a tag with no description clears the excerpt). Its body
749
812
  // and any other frontmatter are the user's and are kept.
750
- const updated = updateFrontmatter(indexPath, data => {
751
- data.title = title;
752
- if (description) data.excerpt = description;
753
- else delete data.excerpt;
754
- });
813
+ const updated = updateFrontmatter(
814
+ indexPath,
815
+ data => {
816
+ data.title = title;
817
+ if (description) data.excerpt = description;
818
+ else delete data.excerpt;
819
+ },
820
+ assertContained,
821
+ );
755
822
  if (updated) changes.updated.push(path.relative(refDir, indexPath));
756
823
  }
757
- addToOrder(path.join(refDir, infoTitle, '_order.yaml'), actualFolder);
758
- addToOrder(path.join(refDir, '_order.yaml'), infoTitle);
824
+ addToOrder(path.join(refDir, infoTitle, '_order.yaml'), actualFolder, assertContained);
825
+ addToOrder(path.join(refDir, '_order.yaml'), infoTitle, assertContained);
759
826
  }
760
827
 
761
828
  // Where each of this spec's operations ends up this run, by operationKey.
@@ -783,6 +850,8 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
783
850
  path.dirname(filePath) !== targetDir
784
851
  ) {
785
852
  const target = path.join(targetDir, path.basename(filePath));
853
+ assertContained(filePath);
854
+ assertContained(target);
786
855
  if (fs.existsSync(target)) {
787
856
  changes.skipped.push({ path: path.relative(refDir, target), operationId: op.operationId });
788
857
  } else {
@@ -790,8 +859,8 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
790
859
  const slug = path.basename(filePath, '.md');
791
860
  fs.mkdirSync(targetDir, { recursive: true });
792
861
  fs.renameSync(filePath, target);
793
- removeFromOrder(path.join(fromDir, '_order.yaml'), slug);
794
- addToOrder(path.join(targetDir, '_order.yaml'), slug);
862
+ removeFromOrder(path.join(fromDir, '_order.yaml'), slug, assertContained);
863
+ addToOrder(path.join(targetDir, '_order.yaml'), slug, assertContained);
795
864
  vacatedDirs.add(fromDir);
796
865
  changes.moved.push({ from: page.relativePath, to: path.relative(refDir, target) });
797
866
  filePath = target;
@@ -803,9 +872,13 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
803
872
  // its own `hidden` — removing the extension never unhides a page.
804
873
  if (op.xInternal.present) {
805
874
  const hidden = Boolean(op.xInternal.value);
806
- const updated = updateFrontmatter(filePath, data => {
807
- data.hidden = hidden;
808
- });
875
+ const updated = updateFrontmatter(
876
+ filePath,
877
+ data => {
878
+ data.hidden = hidden;
879
+ },
880
+ assertContained,
881
+ );
809
882
  if (updated) changes.updated.push(path.relative(refDir, filePath));
810
883
  }
811
884
 
@@ -826,9 +899,12 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
826
899
  const slug = reserveSlug(takenSlugs, safeSegment(op.operationId, 'operation').toLowerCase());
827
900
  const pagePath = path.join(pageDir, `${slug}.md`);
828
901
 
829
- // Guard against a spec-crafted name escaping reference/, or a stale slug set
830
- // vs. disk. reserveSlug already prevents slug collisions.
831
- if (!isWithin(refDir, pagePath) || fs.existsSync(pagePath)) {
902
+ // A spec-crafted name that still escapes reference/, or a symlink anywhere
903
+ // on the way there, fails the sync instead of being skipped quietly.
904
+ // reserveSlug already prevents slug collisions; an existing real file is
905
+ // the only skip left.
906
+ assertContained(pagePath);
907
+ if (fs.existsSync(pagePath)) {
832
908
  changes.skipped.push({ path: path.relative(refDir, pagePath), operationId: op.operationId });
833
909
  continue;
834
910
  }
@@ -842,7 +918,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
842
918
  });
843
919
  fs.writeFileSync(pagePath, content);
844
920
 
845
- addToOrder(path.join(pageDir, '_order.yaml'), slug);
921
+ addToOrder(path.join(pageDir, '_order.yaml'), slug, assertContained);
846
922
 
847
923
  changes.added.push(path.relative(refDir, pagePath));
848
924
  opPaths.set(key, pagePath);
@@ -865,9 +941,13 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
865
941
  const isNew = createdIndexes.has(indexPath);
866
942
  const allHidden = children.every(x => x.present && (isNew ? Boolean(x.value) : x.value === true));
867
943
  if (!allHidden) continue;
868
- const updated = updateFrontmatter(indexPath, data => {
869
- data.hidden = true;
870
- });
944
+ const updated = updateFrontmatter(
945
+ indexPath,
946
+ data => {
947
+ data.hidden = true;
948
+ },
949
+ assertContained,
950
+ );
871
951
  if (updated && !isNew) changes.updated.push(path.relative(refDir, indexPath));
872
952
  }
873
953
 
@@ -875,7 +955,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
875
955
  // category folder, and never a folder an operation still lives in.
876
956
  for (const dir of vacatedDirs) {
877
957
  if (!isWithin(apiDir, dir) || childrenByDir.has(dir)) continue;
878
- cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes });
958
+ cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes, assertContained });
879
959
  }
880
960
 
881
961
  // `apply-endpoint-order`: reorder each folder's operation pages to match
@@ -899,7 +979,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
899
979
  const current = fs.existsSync(orderPath) ? parseOrderYaml(fs.readFileSync(orderPath, 'utf-8')) : [];
900
980
  const next = applyOASOrder(current, slugs);
901
981
  if (next.join('\n') === current.join('\n')) continue;
902
- writeOrderYaml(orderPath, next);
982
+ writeOrderYaml(orderPath, next, assertContained);
903
983
  changes.updated.push(path.relative(refDir, orderPath));
904
984
  }
905
985
  }
@@ -918,7 +998,9 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
918
998
  *
919
999
  * Pure programmatic API: returns per-file change descriptors and prints
920
1000
  * nothing. Used by the CLI command, the lint --fix flow, and external
921
- * callers.
1001
+ * callers. Throws `PathEscapeError` if `reference/` contains a symlink
1002
+ * (before anything is modified) or a destination would leave it.
1003
+ * Callers report that and exit non-zero.
922
1004
  *
923
1005
  * @param {string | { cwd?: string }} input Repo root path, or `{ cwd }` object.
924
1006
  * @returns {null | Array<{ filename: string, spec: object, opCount: number,
@@ -931,9 +1013,18 @@ export function syncOas(input) {
931
1013
  const refDir = path.join(gitRoot, 'reference');
932
1014
  if (!fs.existsSync(refDir)) return null;
933
1015
 
1016
+ // Before the no-spec return and before any write. `findOasFiles` skips
1017
+ // symlink specs, so a reference/ whose only spec is a link would otherwise
1018
+ // report success and leave pages unsynced. Refusing first also keeps a later
1019
+ // real spec from being half-applied, and keeps a link the page walk would
1020
+ // have skipped from being deleted or written through.
1021
+ refuseSymlinks(refDir);
1022
+
934
1023
  const oasFiles = findOasFiles(refDir);
935
1024
  if (oasFiles.length === 0) return null;
936
1025
 
1026
+ const assertContained = makeContainment(refDir);
1027
+
937
1028
  // Reference slugs share one flat namespace across every spec, so build the set
938
1029
  // of in-use slugs once and let each spec read/extend it.
939
1030
  const takenSlugs = collectReferenceSlugs(refDir);
@@ -941,7 +1032,7 @@ export function syncOas(input) {
941
1032
 
942
1033
  for (const { filename, spec } of oasFiles) {
943
1034
  const ops = extractOperations(spec);
944
- const changes = syncOneOas(refDir, filename, spec, takenSlugs);
1035
+ const changes = syncOneOas(refDir, filename, spec, takenSlugs, assertContained);
945
1036
  allChanges.push({ filename, spec, opCount: ops.size, changes });
946
1037
  }
947
1038
 
@@ -1032,7 +1123,23 @@ export async function run(_options, _cmd, ctx) {
1032
1123
  process.exit(1);
1033
1124
  }
1034
1125
 
1035
- const results = syncOas(gitRoot);
1126
+ let results;
1127
+ try {
1128
+ results = syncOas(gitRoot);
1129
+ } catch (err) {
1130
+ if (!(err instanceof PathEscapeError)) throw err;
1131
+ styles.error(err.message);
1132
+ writeGithubActionsOutputs({
1133
+ 'added-count': '0',
1134
+ 'deleted-count': '0',
1135
+ 'moved-count': '0',
1136
+ 'updated-count': '0',
1137
+ 'skipped-count': '0',
1138
+ skipped: [],
1139
+ 'has-errors': 'true',
1140
+ });
1141
+ process.exit(1);
1142
+ }
1036
1143
 
1037
1144
  if (!results) {
1038
1145
  styles.info('No OpenAPI spec files found in reference/.');
@@ -6,6 +6,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
6
6
  import { InvalidArgumentError, Option } from 'commander';
7
7
 
8
8
  import { prettifyPagePrompt, prettifyPageOutputSchema } from '../prompts/index.js';
9
+ import { makeContainment, PathEscapeError } from '../utils/containment.js';
9
10
  import { printHeader, isAgenticCli } from '../utils/eyes.js';
10
11
  import { collectFiles } from '../utils/lint.js';
11
12
  import { parseFrontmatter, toFrontmatter, frontmatterWriteError } from '../utils/markdown.js';
@@ -205,6 +206,10 @@ export async function run(file, options, _cmd, ctx) {
205
206
  console.log();
206
207
  }
207
208
 
209
+ // Lexical `path.relative` treats a symlink inside the repo as inside it,
210
+ // and the later write follows the link. Same check `oas:sync` uses.
211
+ const assertContained = makeContainment(gitRoot, { label: 'repository' });
212
+
208
213
  let files;
209
214
  if (file) {
210
215
  const absFile = path.resolve(file);
@@ -212,12 +217,16 @@ export async function run(file, options, _cmd, ctx) {
212
217
  styles.error(`File not found: ${styles.bold(file)}`);
213
218
  process.exit(1);
214
219
  }
215
- const relFile = path.relative(gitRoot, absFile);
216
- if (relFile.startsWith('..') || path.isAbsolute(relFile)) {
217
- styles.error(`File must be inside the repo: ${styles.bold(file)}`);
220
+
221
+ try {
222
+ assertContained(absFile);
223
+ } catch (err) {
224
+ if (!(err instanceof PathEscapeError)) throw err;
225
+ styles.error(err.message);
218
226
  process.exit(1);
219
227
  }
220
- files = [relFile];
228
+
229
+ files = [path.relative(gitRoot, absFile)];
221
230
  } else {
222
231
  files = collectFiles(gitRoot);
223
232
  }
@@ -290,6 +299,7 @@ export async function run(file, options, _cmd, ctx) {
290
299
 
291
300
  try {
292
301
  const filePath = path.join(gitRoot, relativePath);
302
+ assertContained(filePath);
293
303
  const original = fs.readFileSync(filePath, 'utf-8');
294
304
  const parsed = parseFrontmatter(original);
295
305
  const parseError = frontmatterWriteError(parsed);
@@ -0,0 +1,87 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * A path that would leave `baseDir`, including by following a symlink. `target` is the refused
6
+ * path (the symlink itself when that is what was hit).
7
+ */
8
+ export class PathEscapeError extends Error {
9
+ constructor(target, reason) {
10
+ super(`${target}: ${reason}`);
11
+ this.name = 'PathEscapeError';
12
+ this.target = target;
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Guard for "this path stays inside `baseDir`" that does not trust the lexical path.
18
+ *
19
+ * `path.relative` only compares strings. A directory symlink committed under the base (git records
20
+ * it as mode 120000, so a clone recreates it) still looks inside the base, and `mkdir`,
21
+ * `writeFile`, `rename`, `unlink`, and `rm` all follow it. Walk each segment with `lstat` so a
22
+ * symlink is refused even when its target is also inside the base, and `realpath` the nearest
23
+ * existing ancestor so a link that points out of the base cannot hide behind a not-yet-created
24
+ * child.
25
+ *
26
+ * Paths that do not exist yet are allowed when that ancestor is a real directory inside the base —
27
+ * callers are about to create them.
28
+ *
29
+ * @param {string} baseDir Existing directory that bounds every path.
30
+ * @param {{ label?: string }} [opts] Noun used in the error (`reference directory`).
31
+ * @returns {(target: string) => void}
32
+ */
33
+ export function makeContainment(baseDir, { label = 'reference directory' } = {}) {
34
+ const baseReal = fs.realpathSync(baseDir);
35
+ const baseResolved = path.resolve(baseDir);
36
+
37
+ return function assertContained(target) {
38
+ const resolved = path.resolve(target);
39
+ const rel = path.relative(baseResolved, resolved);
40
+
41
+ // '' is the base itself. Callers pass a child, so deleting or writing the base is not a
42
+ // contained operation.
43
+ if (rel === '' || escapes(rel)) {
44
+ throw new PathEscapeError(resolved, `path escapes the ${label}`);
45
+ }
46
+
47
+ let cursor = baseReal;
48
+ for (const segment of rel.split(path.sep)) {
49
+ if (segment === '' || segment === '.' || segment === '..') {
50
+ throw new PathEscapeError(resolved, `path escapes the ${label}`);
51
+ }
52
+
53
+ cursor = path.join(cursor, segment);
54
+
55
+ let st;
56
+ try {
57
+ st = fs.lstatSync(cursor);
58
+ } catch (err) {
59
+ if (err.code !== 'ENOENT') throw err;
60
+
61
+ // `cursor` is the first missing segment. Its parent is either the base (relative path '')
62
+ // or a segment just lstat'd as a real directory. realpath it anyway so a link above the
63
+ // base cannot hide behind a not-yet-created child.
64
+ const parentRel = path.relative(baseReal, fs.realpathSync(path.dirname(cursor)));
65
+ if (escapes(parentRel)) {
66
+ throw new PathEscapeError(resolved, `path escapes the ${label}`);
67
+ }
68
+
69
+ return;
70
+ }
71
+
72
+ if (st.isSymbolicLink()) {
73
+ throw new PathEscapeError(cursor, `symbolic links inside the ${label} are not followed`);
74
+ }
75
+ }
76
+
77
+ const realRel = path.relative(baseReal, fs.realpathSync(cursor));
78
+ if (realRel === '' || escapes(realRel)) {
79
+ throw new PathEscapeError(resolved, `path escapes the ${label}`);
80
+ }
81
+ };
82
+ }
83
+
84
+ /** True when `path.relative(base, candidate)` climbed out of `base`. '' did not. */
85
+ function escapes(rel) {
86
+ return rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
87
+ }
@@ -1,6 +1,5 @@
1
1
  import { execSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
- import { createRequire } from 'node:module';
4
3
  import path from 'node:path';
5
4
  import { fileURLToPath } from 'node:url';
6
5
 
@@ -19,8 +18,6 @@ import {
19
18
  import * as styles from './styles.js';
20
19
  import { getRandomTip } from './tips.js';
21
20
 
22
- const require = createRequire(import.meta.url);
23
-
24
21
  const isRunningInClaude = !!process.env.CLAUDECODE;
25
22
 
26
23
  const CATEGORY_LABELS = {
@@ -178,7 +175,7 @@ export function createHumanReporter() {
178
175
  if (isRunningInClaude && unfixed.length > 0) {
179
176
  const cliRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '../..');
180
177
 
181
- const gitFormatDir = path.dirname(require.resolve('@readmeio/git-format/package.json'));
178
+ const gitFormatDir = path.join(cliRoot, 'vendor/git-format');
182
179
  const claudeMdPath = path.join(gitFormatDir, 'CLAUDE.md');
183
180
  const toolsMdPath = path.join(cliRoot, 'vendor/TOOLS.md');
184
181
  const schemaPath = path.join(gitFormatDir, 'frontmatter.schema.json');