11// Tests safe filesystem wrappers and protected file-handle behavior.
22import type { FileHandle } from "node:fs/promises" ;
33import fs from "node:fs/promises" ;
4+ import fsSync from "node:fs" ;
5+ import os from "node:os" ;
46import path from "node:path" ;
57import { __setFsSafeTestHooksForTest } from "@openclaw/fs-safe/test-hooks" ;
8+
9+ const canCreateFileSymlinks = ( ( ) => {
10+ const probeDir = fsSync . mkdtempSync ( path . join ( os . tmpdir ( ) , "openclaw-fs-file-symlink-probe-" ) ) ;
11+ const targetFile = path . join ( probeDir , "target.txt" ) ;
12+ const linkFile = path . join ( probeDir , "link.txt" ) ;
13+ try {
14+ fsSync . writeFileSync ( targetFile , "target" , "utf8" ) ;
15+ fsSync . symlinkSync ( targetFile , linkFile , "file" ) ;
16+ return true ;
17+ } catch {
18+ return false ;
19+ } finally {
20+ fsSync . rmSync ( probeDir , { recursive : true , force : true } ) ;
21+ }
22+ } ) ( ) ;
23+
24+ const canCreateDirectorySymlinks = ( ( ) => {
25+ const probeDir = fsSync . mkdtempSync ( path . join ( os . tmpdir ( ) , "openclaw-fs-dir-symlink-probe-" ) ) ;
26+ const targetDir = path . join ( probeDir , "target" ) ;
27+ const linkDir = path . join ( probeDir , "link" ) ;
28+ try {
29+ fsSync . mkdirSync ( targetDir ) ;
30+ fsSync . symlinkSync ( targetDir , linkDir , process . platform === "win32" ? "junction" : "dir" ) ;
31+ return true ;
32+ } catch {
33+ return false ;
34+ } finally {
35+ fsSync . rmSync ( probeDir , { recursive : true , force : true } ) ;
36+ }
37+ } ) ( ) ;
638import { afterEach , describe , expect , it , vi } from "vitest" ;
739import { withEnv , withEnvAsync } from "../test-utils/env.js" ;
840import {
@@ -163,7 +195,7 @@ describe("fs-safe", () => {
163195 await expectRejectCode ( readLocalFileSafely ( { filePath : file , maxBytes : 4 } ) , "too-large" ) ;
164196 } ) ;
165197
166- it . runIf ( process . platform !== "win32" ) ( "rejects symlinks" , async ( ) => {
198+ it . skipIf ( ! canCreateFileSymlinks ) ( "rejects symlinks" , async ( ) => {
167199 const dir = await tempDirs . make ( "openclaw-fs-safe-" ) ;
168200 const target = path . join ( dir , "target.txt" ) ;
169201 const link = path . join ( dir , "link.txt" ) ;
@@ -280,7 +312,7 @@ describe("fs-safe", () => {
280312 await expect ( readScoped ( scopedPath ) ) . resolves . toEqual ( Buffer . from ( "scoped" ) ) ;
281313 } ) ;
282314
283- it . runIf ( process . platform !== "win32" ) ( "blocks symlink escapes under root" , async ( ) => {
315+ it . skipIf ( ! canCreateFileSymlinks ) ( "blocks symlink escapes under root" , async ( ) => {
284316 const root = await tempDirs . make ( "openclaw-fs-safe-root-" ) ;
285317 const outside = await tempDirs . make ( "openclaw-fs-safe-outside-" ) ;
286318 const target = path . join ( outside , "outside.txt" ) ;
@@ -291,7 +323,7 @@ describe("fs-safe", () => {
291323 await expectRejectCode ( ( await openRoot ( root ) ) . open ( "link.txt" ) , "symlink" ) ;
292324 } ) ;
293325
294- it . runIf ( process . platform !== "win32" ) (
326+ it . skipIf ( ! canCreateFileSymlinks ) (
295327 "rejects symlink-target reads when the path target changes after open" ,
296328 async ( ) => {
297329 const root = await tempDirs . make ( "openclaw-fs-safe-root-" ) ;
@@ -413,14 +445,14 @@ describe("fs-safe", () => {
413445 expect ( stat . isDirectory ( ) ) . toBe ( true ) ;
414446 } ) ;
415447
416- it . runIf ( process . platform !== "win32" ) (
448+ it . skipIf ( ! canCreateDirectorySymlinks ) (
417449 "creates directories through in-root symlink parents" ,
418450 async ( ) => {
419451 const root = await tempDirs . make ( "openclaw-fs-safe-root-" ) ;
420452 const realDir = path . join ( root , "real" ) ;
421453 const aliasDir = path . join ( root , "alias" ) ;
422454 await fs . mkdir ( realDir , { recursive : true } ) ;
423- await fs . symlink ( realDir , aliasDir ) ;
455+ await fs . symlink ( realDir , aliasDir , process . platform === "win32" ? "junction" : "dir" ) ;
424456
425457 await ( await openRoot ( root ) ) . mkdir ( path . join ( "alias" , "nested" , "deeper" ) ) ;
426458
@@ -429,14 +461,14 @@ describe("fs-safe", () => {
429461 } ,
430462 ) ;
431463
432- it . runIf ( process . platform !== "win32" ) (
464+ it . skipIf ( ! canCreateDirectorySymlinks ) (
433465 "removes files through in-root symlink parents" ,
434466 async ( ) => {
435467 const root = await tempDirs . make ( "openclaw-fs-safe-root-" ) ;
436468 const realDir = path . join ( root , "real" ) ;
437469 const aliasDir = path . join ( root , "alias" ) ;
438470 await fs . mkdir ( realDir , { recursive : true } ) ;
439- await fs . symlink ( realDir , aliasDir ) ;
471+ await fs . symlink ( realDir , aliasDir , process . platform === "win32" ? "junction" : "dir" ) ;
440472 await fs . writeFile ( path . join ( realDir , "target.txt" ) , "hello" ) ;
441473
442474 await ( await openRoot ( root ) ) . remove ( path . join ( "alias" , "target.txt" ) ) ;
@@ -543,7 +575,7 @@ describe("fs-safe", () => {
543575 await expect ( fs . readFile ( outsideTarget , "utf8" ) ) . resolves . toBe ( "X" . repeat ( 4096 ) ) ;
544576 } ) ;
545577
546- it . runIf ( process . platform !== "win32" ) (
578+ it . skipIf ( ! canCreateFileSymlinks ) (
547579 "does not unlink out-of-root file when symlink retarget races remove" ,
548580 async ( ) => {
549581 const { root, outside, slot, outsideTarget } = await setupSymlinkWriteRaceFixture ( {
@@ -567,7 +599,7 @@ describe("fs-safe", () => {
567599 } ,
568600 ) ;
569601
570- it . runIf ( process . platform !== "win32" ) (
602+ it . skipIf ( ! canCreateFileSymlinks ) (
571603 "does not create out-of-root directories when symlink retarget races mkdir" ,
572604 async ( ) => {
573605 const root = await tempDirs . make ( "openclaw-fs-safe-root-" ) ;
0 commit comments