@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.
- package/dist/auto-sync.d.ts +34 -0
- package/dist/auto-sync.js +167 -29
- package/package.json +1 -1
package/dist/auto-sync.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
541
|
-
//
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|