@relayfile/local-mount 0.10.37 → 0.10.39

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.
@@ -105,3 +105,37 @@ export interface FileState {
105
105
  projectMtimeMs?: number;
106
106
  }
107
107
  export declare function startAutoSync(ctx: AutoSyncContext, opts?: AutoSyncOptions): AutoSyncHandle;
108
+ /** @internal exported for the adversarial confinement suite. */
109
+ export declare function isSymlinkTarget(target: string): boolean;
110
+ /**
111
+ * Exported for the adversarial confinement suite in
112
+ * auto-sync-confinement.test.ts. Not part of the package's public API — the
113
+ * test drives the real resolver rather than a copy of it, because a copy proves
114
+ * nothing about this code.
115
+ */
116
+ export declare function resolveSafeWriteTarget(root: string, candidate: string): string | null;
117
+ /**
118
+ * Copy `source` onto `target` without ever writing *through* whatever `target`
119
+ * currently names.
120
+ *
121
+ * The content is written to a temporary sibling inside the already-validated
122
+ * parent directory and then renamed over the target. That is what makes this
123
+ * safe, and it closes two confirmed escapes that a check-then-copy sequence
124
+ * could not:
125
+ *
126
+ * - **Hardlink.** A hardlink inside the root pointing at a file outside it is
127
+ * path-indistinguishable from a real file and `realpath` cannot resolve it,
128
+ * because a hardlink has no target. `copyFileSync` onto that name wrote
129
+ * straight through to the outside file. `rename` replaces the *directory
130
+ * entry* instead, so the linked file keeps its content.
131
+ *
132
+ * - **TOCTOU.** `isSymlinkTarget(target)` followed by `copyFileSync(target)`
133
+ * is two path lookups, and a target swapped for a symlink in between was
134
+ * followed. `rename` does not follow a final symlink — it replaces it.
135
+ *
136
+ * It also makes the write atomic: a reader sees the old file or the new one,
137
+ * never a partial or zero-length one, and an interrupted copy leaves the target
138
+ * untouched. Reflink cloning is preserved, since the copy into the temporary
139
+ * file still uses COPYFILE_FICLONE.
140
+ */
141
+ export declare function safeCopyOnto(source: string, target: string, mode?: number): boolean;
package/dist/auto-sync.js CHANGED
@@ -1,4 +1,5 @@
1
- import { chmodSync, constants as fsConstants, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, } from 'node:fs';
1
+ import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, lstatSync, mkdirSync, openSync, unlinkSync, renameSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, } from 'node:fs';
2
+ import { randomBytes } from 'node:crypto';
2
3
  import path from 'node:path';
3
4
  import watcher from '@parcel/watcher';
4
5
  import { preserveMtime, statsImplySameContent } from './stat-compare.js';
@@ -520,7 +521,8 @@ function doMountToProject(relPosix, state, ctx, mountAbs, projectAbs) {
520
521
  updateState(state, relPosix, mountAbs, target);
521
522
  return false;
522
523
  }
523
- copyFileSync(mountAbs, target, fsConstants.COPYFILE_FICLONE);
524
+ if (!safeCopyOnto(mountAbs, target))
525
+ return false;
524
526
  const mountStat = safeFileStat(mountAbs);
525
527
  if (mountStat)
526
528
  preserveMtime(target, mountStat);
@@ -537,33 +539,16 @@ function doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly)
537
539
  updateState(state, relPosix, target, projectAbs);
538
540
  return false;
539
541
  }
540
- // The mount copy of a readonly file has mode 0o444, which blocks
541
- // copyFileSync from overwriting it. Temporarily restore write permission.
542
- if (existsSync(target)) {
543
- try {
544
- chmodSync(target, 0o644);
545
- }
546
- catch { /* best effort */ }
547
- }
548
- copyFileSync(projectAbs, target, fsConstants.COPYFILE_FICLONE);
542
+ // The mode is applied to the temporary file before the rename, so a readonly
543
+ // (0o444) mount copy no longer has to be chmod'd writable first. That
544
+ // temporary un-protection was a small window in which the readonly guarantee
545
+ // did not hold; renaming over the target removes the need for it entirely.
549
546
  const sourceStat = safeFileStat(projectAbs);
547
+ const mode = readonly ? 0o444 : sourceStat?.mode !== undefined ? sourceStat.mode & 0o777 : undefined;
548
+ if (!safeCopyOnto(projectAbs, target, mode))
549
+ return false;
550
550
  if (sourceStat)
551
551
  preserveMtime(target, sourceStat);
552
- if (readonly) {
553
- try {
554
- chmodSync(target, 0o444);
555
- }
556
- catch { /* best effort */ }
557
- }
558
- else {
559
- const mode = safeFileStat(projectAbs)?.mode;
560
- if (mode !== undefined) {
561
- try {
562
- chmodSync(target, mode & 0o777);
563
- }
564
- catch { /* best effort */ }
565
- }
566
- }
567
552
  updateState(state, relPosix, target, projectAbs);
568
553
  return true;
569
554
  }
@@ -631,7 +616,8 @@ function safeFileStat(p) {
631
616
  return null;
632
617
  }
633
618
  }
634
- function isSymlinkTarget(target) {
619
+ /** @internal exported for the adversarial confinement suite. */
620
+ export function isSymlinkTarget(target) {
635
621
  // If the target already exists as a symlink, writing through it would
636
622
  // follow the link and potentially escape the mount/project root. Refuse.
637
623
  try {
@@ -669,7 +655,15 @@ function sameContentBytes(left, right) {
669
655
  return false;
670
656
  }
671
657
  }
672
- function resolveSafeWriteTarget(root, candidate) {
658
+ /**
659
+ * Exported for the adversarial confinement suite in
660
+ * auto-sync-confinement.test.ts. Not part of the package's public API — the
661
+ * test drives the real resolver rather than a copy of it, because a copy proves
662
+ * nothing about this code.
663
+ */
664
+ export function resolveSafeWriteTarget(root, candidate) {
665
+ // `root` must already be realpath'd — the only caller does this at
666
+ // mount.ts:195. Passing an unresolved root will reject everything.
673
667
  const resolvedRoot = path.resolve(root);
674
668
  const resolvedCandidate = path.resolve(candidate);
675
669
  if (resolvedCandidate !== resolvedRoot &&
@@ -678,7 +672,17 @@ function resolveSafeWriteTarget(root, candidate) {
678
672
  }
679
673
  const parent = path.dirname(resolvedCandidate);
680
674
  try {
681
- mkdirSync(parent, { recursive: true });
675
+ // Directories are created one component at a time, refusing to traverse a
676
+ // symlink, and only after the component is known to be safe.
677
+ //
678
+ // `mkdirSync(parent, { recursive: true })` used to run BEFORE the resolved
679
+ // parent was validated, so a symlinked component caused directories to be
680
+ // created outside the root and only then was the write refused — a refusal
681
+ // with a side effect. `recursive: true` also creates *through* a symlinked
682
+ // component, which is the traversal it was supposed to prevent.
683
+ if (!createDirectoriesWithin(resolvedRoot, parent)) {
684
+ return null;
685
+ }
682
686
  const realParent = realpathSync(parent);
683
687
  if (realParent !== resolvedRoot &&
684
688
  !realParent.startsWith(`${resolvedRoot}${path.sep}`)) {
@@ -690,6 +694,140 @@ function resolveSafeWriteTarget(root, candidate) {
690
694
  return null;
691
695
  }
692
696
  }
697
+ /**
698
+ * Create every missing component of `dir` beneath `root`, one at a time,
699
+ * refusing to follow or create through a symlink. Returns false on the first
700
+ * component that is not a real directory.
701
+ */
702
+ function createDirectoriesWithin(root, dir) {
703
+ if (dir === root)
704
+ return true;
705
+ const relative = path.relative(root, dir);
706
+ if (relative.startsWith('..') || path.isAbsolute(relative))
707
+ return false;
708
+ // Each component is opened and held for the duration, and — where the
709
+ // platform allows — the next component is resolved *relative to that
710
+ // descriptor* rather than by recomputed path.
711
+ //
712
+ // Checking a component with `lstat` and then creating the next one by path is
713
+ // a race: an already-accepted directory can be swapped for an
714
+ // outside-directed symlink before the following segment is created, and
715
+ // `mkdirSync` would then create directories outside the root. Resolving
716
+ // through the held descriptor means a swap on disk cannot redirect the
717
+ // create, because the descriptor still refers to the directory that was
718
+ // validated.
719
+ //
720
+ // On Linux `/proc/self/fd/<fd>/name` gives that resolution from the kernel.
721
+ // Elsewhere there is no equivalent without a native `openat`, so the walk
722
+ // falls back to paths and the held descriptors serve a narrower purpose: they
723
+ // pin each inode so it cannot be freed and recycled, which keeps the caller's
724
+ // subsequent `realpath` containment check meaningful. The write is still
725
+ // refused in that case — the residual is that a directory may have been
726
+ // created outside the root before the refusal.
727
+ const held = [];
728
+ try {
729
+ let parentFd = openSync(root, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY);
730
+ held.push(parentFd);
731
+ let currentPath = root;
732
+ for (const segment of relative.split(path.sep)) {
733
+ if (!segment)
734
+ continue;
735
+ currentPath = path.join(currentPath, segment);
736
+ const childPath = DESCRIPTOR_RELATIVE
737
+ ? `/proc/self/fd/${parentFd}/${segment}`
738
+ : currentPath;
739
+ const info = lstatSync(childPath, { throwIfNoEntry: false });
740
+ if (!info) {
741
+ mkdirSync(childPath); // one component, never recursive
742
+ }
743
+ else if (info.isSymbolicLink() || !info.isDirectory()) {
744
+ // Refused rather than followed.
745
+ return false;
746
+ }
747
+ // O_NOFOLLOW so a symlink that appeared since the lstat cannot be opened;
748
+ // O_DIRECTORY so anything no longer a directory cannot be either.
749
+ parentFd = openSync(childPath, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
750
+ held.push(parentFd);
751
+ }
752
+ return true;
753
+ }
754
+ catch {
755
+ return false;
756
+ }
757
+ finally {
758
+ for (const fd of held) {
759
+ try {
760
+ closeSync(fd);
761
+ }
762
+ catch { /* already closed */ }
763
+ }
764
+ }
765
+ }
766
+ /** True where `/proc/self/fd` provides kernel-side descriptor-relative resolution. */
767
+ const DESCRIPTOR_RELATIVE = process.platform === 'linux';
768
+ /**
769
+ * Copy `source` onto `target` without ever writing *through* whatever `target`
770
+ * currently names.
771
+ *
772
+ * The content is written to a temporary sibling inside the already-validated
773
+ * parent directory and then renamed over the target. That is what makes this
774
+ * safe, and it closes two confirmed escapes that a check-then-copy sequence
775
+ * could not:
776
+ *
777
+ * - **Hardlink.** A hardlink inside the root pointing at a file outside it is
778
+ * path-indistinguishable from a real file and `realpath` cannot resolve it,
779
+ * because a hardlink has no target. `copyFileSync` onto that name wrote
780
+ * straight through to the outside file. `rename` replaces the *directory
781
+ * entry* instead, so the linked file keeps its content.
782
+ *
783
+ * - **TOCTOU.** `isSymlinkTarget(target)` followed by `copyFileSync(target)`
784
+ * is two path lookups, and a target swapped for a symlink in between was
785
+ * followed. `rename` does not follow a final symlink — it replaces it.
786
+ *
787
+ * It also makes the write atomic: a reader sees the old file or the new one,
788
+ * never a partial or zero-length one, and an interrupted copy leaves the target
789
+ * untouched. Reflink cloning is preserved, since the copy into the temporary
790
+ * file still uses COPYFILE_FICLONE.
791
+ */
792
+ export function safeCopyOnto(source, target, mode) {
793
+ const dir = path.dirname(target);
794
+ // The temporary name is RANDOM and SHORT, and the copy is EXCLUSIVE. Both
795
+ // properties are load-bearing:
796
+ //
797
+ // - Random + exclusive, because the agent controls the mount. A name
798
+ // derived from the target basename plus pid and a counter is predictable,
799
+ // and `copyFileSync` follows a destination symlink — so the agent could
800
+ // pre-create that exact path pointing at a file outside the mount and the
801
+ // copy would overwrite the victim *before* the safe rename ever ran. That
802
+ // is the same escape this function exists to close, reintroduced by the
803
+ // fix; COPYFILE_EXCL makes the create fail if anything is already there,
804
+ // symlink included, and randomness means there is nothing to pre-empt.
805
+ //
806
+ // - Short and independent of the target basename, because a basename near
807
+ // the filesystem's per-component limit (NAME_MAX, typically 255) would
808
+ // make a derived temporary name too long. `copyFileSync` then fails
809
+ // ENAMETOOLONG, which this function reports as a refusal, and auto-sync
810
+ // silently stops updating that file in either direction.
811
+ const temp = path.join(dir, `.rfsync-${randomBytes(9).toString('hex')}`);
812
+ try {
813
+ copyFileSync(source, temp, fsConstants.COPYFILE_FICLONE | fsConstants.COPYFILE_EXCL);
814
+ if (mode !== undefined) {
815
+ try {
816
+ chmodSync(temp, mode);
817
+ }
818
+ catch { /* best effort */ }
819
+ }
820
+ renameSync(temp, target);
821
+ return true;
822
+ }
823
+ catch {
824
+ try {
825
+ unlinkSync(temp);
826
+ }
827
+ catch { /* best effort */ }
828
+ return false;
829
+ }
830
+ }
693
831
  function walk(root, ctx, visit, signal) {
694
832
  const stack = [root];
695
833
  while (stack.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayfile/local-mount",
3
- "version": "0.10.37",
3
+ "version": "0.10.39",
4
4
  "description": "Create a symlink/copy mount of a project directory with .agentignore/.agentreadonly semantics, then launch a CLI inside it and sync writable changes back on exit",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",