diff --git a/Sources/BashInterpreter/API/Sandbox+BashWorkspace.swift b/Sources/BashInterpreter/API/Sandbox+BashWorkspace.swift deleted file mode 100644 index 2cd13373..00000000 --- a/Sources/BashInterpreter/API/Sandbox+BashWorkspace.swift +++ /dev/null @@ -1,156 +0,0 @@ -import Foundation -import ShellKit - -extension ShellKit.Sandbox { - - /// Build the URL gate the SwiftBash sandbox CLI pairs with the - /// real-disk ``MountedFileSystem`` mounting `workspace` and a - /// per-instance temp dir. - /// - /// The gate accepts two roots: the `workspace` mount and the - /// sandbox's temp dir, in the dir's **host** spelling (the same - /// value `$TMPDIR` carries inside the sandbox). Bash builtins - /// write through `Shell.fileSystem` to the same host directories; - /// SwiftPorts CLIs (fd, rg, jq, …) and the SwiftScript interpreter - /// resolve the same paths through ``Shell/currentDirectory`` / - /// ``Shell/resolve(_:)`` and authorise them here. Because both - /// sides land on the same real-disk files, `cd "$TMPDIR"; mkdir - /// foo; echo > foo/x; fd x foo` finds the file (#48 / #55) — and - /// the same works on hosts where `/tmp` doesn't exist (Windows) or - /// isn't writable (Android emulator) because the backing always - /// lives under the platform's real temp root (#58). - /// - /// The gate does NOT accept the virtual `/tmp` spelling: callers - /// that reach it carry no virtual→host translation (that lands - /// with #83), so a literal `/tmp/...` here would do real I/O on - /// the host's `/tmp` — a directory shared with every other - /// process — instead of this sandbox's temp dir (#82). Until #83, - /// FileManager-backed callers reach scratch via `$TMPDIR`-spelled - /// paths only; bash builtins use `/tmp` as usual through the - /// mount table. - /// - /// The temp carve-out checks **both** the unresolved standardised - /// path (what the script asked for) and the symlink-resolved path - /// (what `FileManager` would actually read) — without the second - /// check a script's `ln -s /etc/passwd "$TMPDIR/p"` would let - /// FileManager-backed bridges follow the link out of the sandbox. - /// The bash-side `MountedFileSystem.canonicalGate` already rejects - /// this; the URL gate has to match. - /// - /// - Parameter temporaryDirectory: the host dir reported as - /// ``Shell/temporaryDirectory`` and accepted by the temp - /// carve-out; it should exist when this factory runs so its - /// symlink-resolved spelling is computed correctly. The CLI - /// passes its per-instance `swiftbash-` dir (#82). - /// Defaults to `NSTemporaryDirectory()` — note that the default - /// shares the platform temp root with every other process and - /// sandbox instance. - /// - Parameter authorizeNetwork: embedder policy for non-file URLs - /// (e.g. routing host access through a permission prompt) while - /// still reusing this file-URL gate — temp carve-out included. - /// When `nil`, non-file URLs are denied (the base gate's - /// `allowedHosts: []`), matching the CLI's offline default. - public static func bashWorkspace( - workspace: String, - temporaryDirectory: URL? = nil, - authorizeNetwork: (@Sendable (URL) async throws -> Void)? = nil - ) -> ShellKit.Sandbox { - let workspaceURL = URL(fileURLWithPath: workspace, - isDirectory: true) - let tmpURL = temporaryDirectory - ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - let tempPrefixes = Self.temporaryPrefixes(for: tmpURL) - let baseSandbox = ShellKit.Sandbox.rooted( - at: workspaceURL, - allowedHosts: []) - return ShellKit.Sandbox( - documentsDirectory: baseSandbox.documentsDirectory, - downloadsDirectory: baseSandbox.downloadsDirectory, - libraryDirectory: baseSandbox.libraryDirectory, - moviesDirectory: baseSandbox.moviesDirectory, - musicDirectory: baseSandbox.musicDirectory, - picturesDirectory: baseSandbox.picturesDirectory, - sharedPublicDirectory: baseSandbox.sharedPublicDirectory, - temporaryDirectory: tmpURL, - trashDirectory: baseSandbox.trashDirectory, - userDirectory: baseSandbox.userDirectory, - cachesDirectory: baseSandbox.cachesDirectory, - homeDirectory: baseSandbox.homeDirectory, - authorize: { url in - // Non-file URLs go through the embedder's network policy - // when supplied; otherwise the deny-all base gate stands. - guard url.isFileURL else { - if let authorizeNetwork { - try await authorizeNetwork(url) - } else { - try await baseSandbox.authorize(url) - } - return - } - do { - try await baseSandbox.authorize(url) - } catch let denial as ShellKit.Sandbox.Denial { - // File URL denied by the workspace root — allow it - // only if it lands under the sandbox's temp dir. - // Compare against `standardizedFileURL` so - // `$TMPDIR/./foo` and `$TMPDIR/foo` agree. - let unresolved = url.standardizedFileURL.path - guard Self.pathIsInTemp(unresolved, - prefixes: tempPrefixes) - else { throw denial } - // Canonical (symlink-resolved) path must stay in a - // temp prefix too — defends against a bash-staged - // `ln -s /etc/passwd "$TMPDIR/p"` escape. - let resolved = url.resolvingSymlinksInPath() - .standardizedFileURL.path - if !Self.pathIsInTemp(resolved, - prefixes: tempPrefixes) { - throw denial - } - } - }) - } - - /// Path prefixes the URL gate treats as "inside this sandbox's - /// temp dir": the dir's verbatim spelling, its normalised one, - /// and its symlink-resolved one (macOS' temp root lives behind - /// the `/var → /private/var` link, so `FileManager`'s canonical - /// paths carry the `/private` form while `$TMPDIR` carries the - /// bare one). The verbatim spelling matters because - /// `standardizingPath` may rewrite it — swift-corelibs-foundation - /// follows symlinks, Darwin strips `/private` — while callers - /// hand the gate paths built from the `$TMPDIR` value exactly as - /// the embedder spelled it. Derived per instance — a sibling - /// sandbox's temp dir under the same platform root never - /// matches (#82). - private static func temporaryPrefixes(for tempDir: URL) -> [String] { - var prefixes: [String] = [] - let normalized = (tempDir.path as NSString).standardizingPath - let resolved = URL(fileURLWithPath: normalized) - .resolvingSymlinksInPath().path - for path in [tempDir.path, normalized, resolved] { - let stripped: String = { - if path.count > 1 && path.hasSuffix("/") { - return String(path.dropLast()) - } - return path - }() - if !prefixes.contains(stripped) { - prefixes.append(stripped) - } - } - return prefixes - } - - /// Whether `path` (unresolved or canonical) names a location under - /// any accepted temp prefix — see ``temporaryPrefixes(for:)``. - private static func pathIsInTemp(_ path: String, - prefixes: [String]) -> Bool { - for prefix in prefixes { - if path == prefix || path.hasPrefix(prefix + "/") { - return true - } - } - return false - } -} diff --git a/Sources/BashInterpreter/API/Shell+Path.swift b/Sources/BashInterpreter/API/Shell+Path.swift index 6f1ff7fa..1ed43dda 100644 --- a/Sources/BashInterpreter/API/Shell+Path.swift +++ b/Sources/BashInterpreter/API/Shell+Path.swift @@ -54,76 +54,13 @@ extension Shell { return false } - /// Lexical path normalisation — collapses `.` / `..` / repeated - /// `/` purely as text, never touching the filesystem. **Does not - /// resolve symlinks** (that's what `cd -L` / `pwd -L` semantics - /// rely on; `cd -P` / `pwd -P` go through - /// ``FileSystem/canonicalize(_:allowMissing:)`` instead). - /// - /// Replaces `NSString.standardizingPath`, which is technically - /// supposed to be lexical but on swift-corelibs-foundation - /// (Linux) follows symlinks too — making `cd -L /var` set `$PWD` - /// to `/private/var` instead of preserving `/var`. - /// - /// On Windows, backslashes are normalised to forward slashes - /// up front (Win32 path APIs accept both). Drive-letter paths - /// keep their `C:` prefix as the root segment so the result is - /// still a valid Windows path: `C:\Users\foo\..\bar` → `C:/Users/bar`. - static func normalizePath(_ path: String) -> String { - guard !path.isEmpty else { return "" } - #if os(Windows) - let normalized = path.replacingOccurrences(of: "\\", with: "/") - #else - let normalized = path - #endif - // Split on `/`, tracking whether the path is anchored at the - // root (Unix `/foo`) or at a drive (Windows `C:/foo`). For a - // drive-letter path we keep the `C:` segment in the stack so - // the rebuilt string still stems from that drive. - let isUnixAbsolute = normalized.hasPrefix("/") - var stack: [String] = [] - var driveRoot: String? - var saw: [Substring] = normalized.split( - separator: "/", omittingEmptySubsequences: true) - #if os(Windows) - // Detect a leading `C:` segment (drive root). After we - // record it, the rest of the segments are walked as if the - // path were absolute beneath that drive. - if let first = saw.first, - first.count == 2, - let firstChar = first.first, firstChar.isLetter, - first.last == ":" { - driveRoot = String(first) - saw = Array(saw.dropFirst()) - } - #endif - let anchored = isUnixAbsolute || driveRoot != nil - for seg in saw { - switch seg { - case ".": - continue - case "..": - // For anchored paths, `..` at the root stays at the - // root. For relative paths we let `..` underflow as - // a literal segment so callers can preserve the - // user's intent (rare in practice). - if !stack.isEmpty, stack.last != ".." { - stack.removeLast() - } else if !anchored { - stack.append("..") - } - default: - stack.append(String(seg)) - } - } - if let driveRoot { - return driveRoot + "/" + stack.joined(separator: "/") - } - if isUnixAbsolute { - return "/" + stack.joined(separator: "/") - } - return stack.isEmpty ? "." : stack.joined(separator: "/") - } + // NB: `normalizePath(_:)` — the lexical `.` / `..` / `//` + // collapse this resolver relies on — moved down to + // `ShellKit.Shell` with #83 so the shared `PathMapping` core and + // this interpreter normalise identically. Call sites are + // unchanged: the static is inherited. (`cd -L` / `pwd -L` + // semantics rely on it being lexical; `cd -P` / `pwd -P` go + // through ``FileSystem/canonicalize(_:allowMissing:)`` instead.) private func expandTilde(_ path: String) -> String { guard path.hasPrefix("~"), diff --git a/Sources/BashInterpreter/FileSystems/MountedFileSystem.swift b/Sources/BashInterpreter/FileSystems/MountedFileSystem.swift index 0b9e7133..fe675880 100644 --- a/Sources/BashInterpreter/FileSystems/MountedFileSystem.swift +++ b/Sources/BashInterpreter/FileSystems/MountedFileSystem.swift @@ -1,4 +1,3 @@ -// swiftlint:disable file_length import Foundation /// A `FileSystem` that presents a virtual root (`/`) backed by one or @@ -17,23 +16,34 @@ import Foundation /// doesn't exist — same model as a chrooted shell. /// /// `MountedFileSystem` is the building block for that. You hand it a -/// list of mount points and a backing FS; it rewrites every virtual -/// path to a host path before delegating, and rejects paths that -/// don't fall inside any mount with `notFound`. +/// ``ShellKit/PathMapping`` (or a list of mount points) and a backing +/// FS; it rewrites every virtual path to a host path before +/// delegating, and rejects paths that don't fall inside any mount +/// with `notFound`. /// /// ```swift -/// let fs = MountedFileSystem( -/// mounts: [ -/// .init(virtual: "/", host: sandboxRoot.path), -/// .init(virtual: "/tmp", host: NSTemporaryDirectory()), -/// ], -/// backing: RealFileSystem()) -/// shell.fileSystem = fs +/// let mapping = PathMapping(mounts: [ +/// .init(virtual: "/", host: sandboxRoot.path), +/// .init(virtual: "/tmp", host: NSTemporaryDirectory()), +/// ]) +/// shell.fileSystem = MountedFileSystem(mapping: mapping, +/// backing: RealFileSystem()) +/// shell.sandbox = .confined(to: mapping) /// shell.environment.workingDirectory = "/home" /// shell.environment["HOME"] = "/home" /// shell.environment["TMPDIR"] = "/tmp" /// ``` /// +/// This is **Facade A** over the shared mapping core (#83): bash +/// builtins and pure-Swift commands route through the `FileSystem` +/// protocol and this class translates + confines each call. Code +/// that does real Foundation/C I/O instead (SwiftPorts CLIs, the JS +/// runtime) goes through **Facade B** — ``ShellKit/Shell/resolve(_:)`` +/// + ``ShellKit/Sandbox/authorize(_:)`` — which, when the embedder +/// installs `Sandbox.confined(to:)` over the *same* mapping, lands +/// on the same host files with the same boundary. One core, two +/// doors. +/// /// Mount precedence is "longest virtual prefix wins" — `/tmp/foo` /// matches the `/tmp` mount, not `/`. Synthetic paths supplied by /// any ``OverlayProvider`` layered above this mount table (e.g. @@ -45,86 +55,46 @@ import Foundation /// `permissionDenied`, `mkdir` throws `alreadyExists`. public final class MountedFileSystem: FileSystem, @unchecked Sendable { - public struct Mount: Sendable { - /// Virtual prefix this mount answers to. `/` matches every - /// virtual path; `/tmp` matches `/tmp` and `/tmp/...`. - public var virtual: String - /// Absolute host path the mount maps onto. - public var host: String - /// If true, every write through this mount is rejected with - /// `permissionDenied`. Reads still pass. - public var readOnly: Bool - - public init(virtual: String, host: String, readOnly: Bool = false) { - // Normalise: strip trailing `/` so `/tmp` and `/tmp/` - // compare equal. The empty string represents the root - // mount specially. - var virtualPath = (virtual as NSString).standardizingPath - if virtualPath.count > 1, virtualPath.hasSuffix("/") { virtualPath.removeLast() } - self.virtual = virtualPath - self.host = (host as NSString).standardizingPath - self.readOnly = readOnly - } - } + /// Mount entries live on the shared ``ShellKit/PathMapping`` core + /// now; the historical `MountedFileSystem.Mount` spelling keeps + /// working. + public typealias Mount = PathMapping.Mount public let backing: any FileSystem - private let mounts: [Mount] + + /// The shared virtual↔host mapping this filesystem translates + /// through — hand the same value to `Sandbox.confined(to:)` so + /// FileManager-backed callers resolve and authorize against the + /// identical table. + public let mapping: PathMapping + /// See `MountedFileSystem+Synthesis.swift`. let synthesizedAncestors: Set - public init(mounts: [Mount], backing: any FileSystem) { - // Longest-prefix-first so the most specific mount wins. - let sorted = mounts.sorted { $0.virtual.count > $1.virtual.count } - self.mounts = sorted + public init(mapping: PathMapping, backing: any FileSystem) { + self.mapping = mapping self.backing = backing - self.synthesizedAncestors = Self.computeSynthesizedAncestors(sorted) + self.synthesizedAncestors = Self.computeSynthesizedAncestors(mapping.mounts) } - var allMountVirtuals: [String] { mounts.map(\.virtual) } + public convenience init(mounts: [Mount], backing: any FileSystem) { + self.init(mapping: PathMapping(mounts: mounts), backing: backing) + } + + var allMountVirtuals: [String] { mapping.mounts.map(\.virtual) } /// Mount table ordered by virtual path, for display by a `mount` - /// command (`mounts` is sorted longest-prefix first internally). - public var mountList: [Mount] { mounts.sorted { $0.virtual < $1.virtual } } + /// command. + public var mountList: [Mount] { mapping.mountList } // MARK: - Mount lookup /// Translate a virtual path to a host path, or return `nil` when - /// no mount matches. `(mount, hostPath, readOnly)`. + /// no mount matches. Lexical only — `..` collapses before routing + /// so it can't escape a mount; symlink confinement is + /// ``canonicalGate(_:virtual:)``'s job. func resolve(_ virtual: String) -> (mount: Mount, host: String)? { - // Standardise the virtual path so `/tmp/../home/foo` resolves - // to `/home/foo` BEFORE we route it. Otherwise `..` could - // escape its mount. - // - // Use `Shell.normalizePath` (purely lexical) rather than - // `NSString.standardizingPath`, which consults the host - // filesystem and resolves any symlinks it finds. On macOS - // `/home` is an autofs symlink to `/System/Volumes/Data/home`, - // so `(/home/..) standardizingPath` yields - // `/System/Volumes/Data` and misses the mount table entirely — - // breaking any script that lands a `..`-crossing virtual path - // here (e.g. tab completion of `cd ../ex` from `/home`). - let std = Shell.normalizePath(virtual) - for mount in mounts { - if mount.virtual == "/" { - // Root mount — every path lands here unless an earlier - // (more specific) mount matched. Strip the leading `/` - // and append. - let rel = std == "/" ? "" : String(std.dropFirst()) - let host = (mount.host as NSString) - .appendingPathComponent(rel) - return (mount, host) - } - if std == mount.virtual { - return (mount, mount.host) - } - if std.hasPrefix(mount.virtual + "/") { - let rel = String(std.dropFirst(mount.virtual.count + 1)) - let host = (mount.host as NSString) - .appendingPathComponent(rel) - return (mount, host) - } - } - return nil + mapping.hostPath(forVirtual: virtual) } private func gateRead(_ path: String) async throws -> String { @@ -365,7 +335,8 @@ public final class MountedFileSystem: FileSystem, @unchecked Sendable { // If the mount table covers `/tmp`, defer to the backing FS // there. Otherwise drop into a hidden `.tmp` directory under // the root mount. - if let tmp = mounts.first(where: { $0.virtual == "/tmp" }), !tmp.readOnly { + if let tmp = mapping.mounts.first(where: { $0.virtual == "/tmp" }), + !tmp.readOnly { try? await backing.createDirectory(tmp.host, intermediates: true) let suffix = String(UUID().uuidString.prefix(12)) return "/tmp/\(prefix)\(suffix)" diff --git a/Sources/SwiftJSCore/Globals+Process.swift b/Sources/SwiftJSCore/Globals+Process.swift index 0a8bb1db..4ed076a4 100644 --- a/Sources/SwiftJSCore/Globals+Process.swift +++ b/Sources/SwiftJSCore/Globals+Process.swift @@ -239,20 +239,27 @@ extension JSRuntime { // expects it to compose with the prior cwd. Storing the // raw relative string here would leave the bound CWD // unusable for subsequent `fs.*` ops. - let resolved = resolveAgainstShellCWD(path) + // + // The STORED cwd stays in the script-visible (virtual) + // spelling — `process.cwd()` reports it and `fs.*` + // re-resolves through it — while the gate checks the + // HOST path that spelling lands on, the same space + // `resolveAgainstShellCWD` produces for I/O. + let virtual = virtualPathAgainstShellCWD(path) + let host = Shell.current.resolve(virtual).path // Sandbox gate: chdir into a denied region is a write — // it would let a script position subsequent relative-path // ops anywhere. Surface as a Node-style EACCES. do { - try self?.awaitSync { try await authorizePath(resolved, for: .write) } + try self?.awaitSync { try await authorizePath(host, for: .write) } } catch { _ = self?.throwSandboxDenial(error, syscall: "chdir", path: path) return nil } if Shell.current === Shell.processDefault { - _ = FileManager.default.changeCurrentDirectoryPath(resolved) + _ = FileManager.default.changeCurrentDirectoryPath(virtual) } - Shell.current.environment.workingDirectory = resolved + Shell.current.environment.workingDirectory = virtual return nil } process.setObject(chdir, forKeyedSubscript: "chdir") diff --git a/Sources/SwiftJSCore/JSRuntime+Exception.swift b/Sources/SwiftJSCore/JSRuntime+Exception.swift index 36927fe6..d1e19924 100644 --- a/Sources/SwiftJSCore/JSRuntime+Exception.swift +++ b/Sources/SwiftJSCore/JSRuntime+Exception.swift @@ -1,6 +1,7 @@ #if !os(Windows) import Foundation +import BashInterpreter // MARK: - Exception formatting // @@ -55,7 +56,15 @@ extension JSRuntime { } else { path = sourceURL } - guard let source = try? String(contentsOfFile: path, encoding: .utf8) else { + // The path travels in the script-visible (virtual) spelling; + // read its HOST translation, and gate it — `sourceURL` is + // script-influenced, so without the gate a thrown exception + // could point this read at any host file and have its + // contents echoed into the error frame. + let hostPath = ShellKit.Shell.current.resolve(path).path + guard (try? awaitSync { try await authorizePath(hostPath, for: .read) }) != nil, + let source = try? String(contentsOfFile: hostPath, encoding: .utf8) + else { return nil } let lines = source.split(omittingEmptySubsequences: false, diff --git a/Sources/SwiftJSCore/Modules+OS.swift b/Sources/SwiftJSCore/Modules+OS.swift index c91d93bd..38c3da3d 100644 --- a/Sources/SwiftJSCore/Modules+OS.swift +++ b/Sources/SwiftJSCore/Modules+OS.swift @@ -34,8 +34,12 @@ extension JSRuntime { if Shell.current === Shell.processDefault { return NSHomeDirectory() } - if let sandboxHome = Shell.current.sandbox?.homeDirectory.path { - return sandboxHome + if let sandboxHome = Shell.current.sandbox?.homeDirectory { + // Region URLs are host-spelled (consumers hand them to + // Foundation); what the script SEES folds back to the + // virtual spelling under a path-mapped sandbox — the + // same answer `$HOME` carries. + return Shell.current.displayPath(for: sandboxHome) } return Shell.current.environment.variables["HOME"] ?? NSHomeDirectory() } @@ -44,8 +48,13 @@ extension JSRuntime { if Shell.current === Shell.processDefault { return NSTemporaryDirectory() } - if let sandboxTmp = Shell.current.sandbox?.temporaryDirectory.path { - return sandboxTmp + if let sandboxTmp = Shell.current.sandbox?.temporaryDirectory { + // Fold the per-instance host dir back to `/tmp` under a + // path-mapped sandbox (#82 / #83): the host path must not + // leak, and anything the script does with the answer + // (`fs.writeFileSync(os.tmpdir() + "/x")`) re-translates + // through `resolveAgainstShellCWD` onto the same dir. + return Shell.current.displayPath(for: sandboxTmp) } return NSTemporaryDirectory() } diff --git a/Sources/SwiftJSCore/Modules.swift b/Sources/SwiftJSCore/Modules.swift index 5d2eefe2..67a1c38a 100644 --- a/Sources/SwiftJSCore/Modules.swift +++ b/Sources/SwiftJSCore/Modules.swift @@ -70,39 +70,58 @@ extension JSRuntime { ?? (Shell.current === Shell.processDefault ? FileManager.default.currentDirectoryPath : Shell.current.environment.workingDirectory) - var resolved = (spec as NSString).hasPrefix("/") + let base = (spec as NSString).hasPrefix("/") ? spec : (basePath as NSString).appendingPathComponent(spec) - // Try `.js`, `.mjs`, `.cjs`, `.json` if the bare path doesn't - // exist (Node's resolution order). `.json` parsed below. + // Candidate spellings in Node's resolution order: the bare + // path, then the implicit extensions. Each is a script-visible + // (virtual) spelling — the cache key, `__filename` / + // `__dirname`, and the stack-frame source URL all carry it; + // its HOST translation (`Shell.resolve`; identity without a + // sandbox path mapping) drives the gate and disk access. + let cacheStore = context.objectForKeyedSubscript("__swiftjs_module_cache") let fileManager = FileManager.default - if !fileManager.fileExists(atPath: resolved) { - for ext in [".js", ".mjs", ".cjs", ".json"] - where fileManager.fileExists(atPath: resolved + ext) { - resolved += ext - break + let candidates = [base] + + [".js", ".mjs", ".cjs", ".json"].map { base + $0 } + + var resolved = "" + var hostPath = "" + var found = false + for candidate in candidates { + // Cache hit short-circuits before any gate / disk touch — + // a cached module was authorized when first loaded. + if let cached = cacheStore?.objectForKeyedSubscript(candidate), + !cached.isUndefined, !cached.isNull { + return cached } + let candidateHost = ShellKit.Shell.current.resolve(candidate).path + // Authorize BEFORE probing the filesystem, and treat a + // denied candidate exactly like a missing one (keep + // looking, ultimately MODULE_NOT_FOUND). Probing first + // would `stat` through a workspace symlink that escapes + // the sandbox before the gate runs, letting a script tell + // an existing outside target from a missing one via the + // error shape (Codex P2 on #88). Folding deny into + // not-found closes that oracle: neither the gate result + // nor a stat reveals anything outside the namespace. + let authorized = (try? awaitSync { + try await authorizePath(candidateHost, for: .read) + }) != nil + guard authorized, + fileManager.fileExists(atPath: candidateHost) + else { continue } + resolved = candidate + hostPath = candidateHost + found = true + break } - - // Cache check (use the resolved absolute path as the key). - if let cached = (context.objectForKeyedSubscript("__swiftjs_module_cache")? - .objectForKeyedSubscript(resolved)), - !cached.isUndefined, !cached.isNull { - return cached - } - - // Sandbox gate: a `require('./secret')` is a read; route the - // resolved path through the bound shell's sandbox before we - // touch disk. - let gatedPath = resolved - do { - try awaitSync { try await authorizePath(gatedPath, for: .read) } - } catch { - return throwSandboxDenial(error, syscall: "open", path: gatedPath) + guard found else { + return throwJSError("Cannot find module '\(spec)'", + code: "MODULE_NOT_FOUND") } - guard let source = try? String(contentsOfFile: resolved, encoding: .utf8) else { + guard let source = try? String(contentsOfFile: hostPath, encoding: .utf8) else { return throwJSError("Cannot find module '\(spec)'", code: "MODULE_NOT_FOUND") } diff --git a/Sources/SwiftJSCore/SandboxBridge.swift b/Sources/SwiftJSCore/SandboxBridge.swift index 99ea76da..1a05d3e4 100644 --- a/Sources/SwiftJSCore/SandboxBridge.swift +++ b/Sources/SwiftJSCore/SandboxBridge.swift @@ -21,8 +21,9 @@ enum PathAccessIntent: Sendable { } /// Resolve a JS-side path against the bound shell's logical CWD when -/// it's relative. Mirrors Node's `process.chdir` semantics: after -/// `process.chdir("/work")`, `readFileSync("./x")` opens `/work/x`. +/// it's relative, staying in the SCRIPT-VISIBLE path space. Mirrors +/// Node's `process.chdir` semantics: after `process.chdir("/work")`, +/// `readFileSync("./x")` means `/work/x`. /// /// Why this matters for sandbox correctness: under a non-default /// `Shell`, `process.chdir(...)` updates `Shell.current.environment @@ -30,16 +31,12 @@ enum PathAccessIntent: Sendable { /// can't, the host is shared with the embedder). Without this /// resolver, every relative-path `fs.*` call would resolve against /// the host CWD via `URL(fileURLWithPath:)` — so `process.cwd()` -/// would diverge from where `readFileSync("./x")` actually reads, -/// and the gate would authorize the wrong path. Pre-resolving here -/// means the gate sees what the script intended, and the Foundation -/// hop that follows opens the same file. +/// would diverge from where `readFileSync("./x")` actually reads. /// -/// Under `Shell.processDefault` the bound CWD mirrors the host -/// process CWD, so the result is identical to plain -/// `URL(fileURLWithPath: path).path` — the standalone `swift-js` CLI -/// behaviour is unchanged. -func resolveAgainstShellCWD(_ path: String) -> String { +/// The result is what the script gets to SEE (`process.chdir` stores +/// it, `__filename` carries it). For the path to DO I/O on, use +/// ``resolveAgainstShellCWD(_:)``, which adds the virtual→host hop. +func virtualPathAgainstShellCWD(_ path: String) -> String { let raw: String if (path as NSString).isAbsolutePath { raw = path @@ -56,6 +53,29 @@ func resolveAgainstShellCWD(_ path: String) -> String { return (raw as NSString).standardizingPath } +/// Resolve a JS-side path to the HOST path to do real I/O on: the +/// virtual resolution of ``virtualPathAgainstShellCWD(_:)``, then — +/// when the bound shell's sandbox carries a `PathMapping` (the +/// SwiftBash `--sandbox` case, #83) — translated to the host +/// directory backing the mount, via `Shell.resolve`. +/// +/// Every `fs.*` bridge resolves through here and uses the result for +/// BOTH the authorize gate and the Foundation hop. That pairing is +/// load-bearing: translating for the check but not the I/O (or vice +/// versa) would authorize one file and touch another — under a +/// sandbox whose virtual `/tmp` is a per-instance dir, a literal +/// `/tmp/x` would otherwise reach the host's *shared* temp dir. +/// Display output keeps the user's own spelling (the original +/// argument), never this host path. +/// +/// Under `Shell.processDefault` (standalone `swift-js`) and under +/// shells without a mapping there is no translation — behaviour is +/// unchanged. +func resolveAgainstShellCWD(_ path: String) -> String { + let virtual = virtualPathAgainstShellCWD(path) + return Shell.current.resolve(virtual).path +} + /// Authorize a filesystem access against the bound shell's sandbox. /// Throws ``ShellKit.Sandbox.Denial`` when the bound sandbox rejects /// the path; returns silently when no sandbox is bound. diff --git a/Sources/swift-bash/ExecCommand.swift b/Sources/swift-bash/ExecCommand.swift index b9b5d941..0ce769dc 100644 --- a/Sources/swift-bash/ExecCommand.swift +++ b/Sources/swift-bash/ExecCommand.swift @@ -36,11 +36,12 @@ struct ExecCommand: AsyncParsableCommand { + "The host directory is mounted at the virtual --workspace " + "path (default /batch); a per-instance temp dir created " + "under the platform's temp root (NSTemporaryDirectory) " - + "is mounted at virtual /tmp and at its own path so " - + "$TMPDIR works too, and is removed when the script ends. " + + "is mounted at virtual /tmp ($TMPDIR carries that " + + "spelling) and is removed when the script ends. " + "Writes inside either mount land on real disk and are " - + "visible to FileManager-backed callers. Paths outside " - + "the mounts return ENOENT.")) + + "visible to FileManager-backed callers, which resolve " + + "the same virtual paths through Shell.resolve. Paths " + + "outside the mounts return ENOENT.")) var sandbox: String? @Option(name: .long, @@ -150,7 +151,7 @@ struct ExecCommand: AsyncParsableCommand { urlSandbox: nil, temporaryDirectory: nil) } - let fileSystem: FileSystem + let fileSystem: MountedFileSystem let tempHost: URL do { (fileSystem, tempHost) = try Self.makeSandboxFileSystem( @@ -171,26 +172,28 @@ struct ExecCommand: AsyncParsableCommand { // and similar HOME-relative idioms a sensible answer. env["HOME"] = workspace env["PWD"] = workspace - // `$TMPDIR` carries the per-instance dir's HOST path, not the - // virtual `/tmp` spelling: FileManager-backed callers - // (SwiftPorts CLIs, SwiftScript bridges) do real I/O on the - // path as given, with no virtual→host translation yet — that - // lands with #83. The host spelling is the one both bash (via - // the identity mount) and those callers (via the URL gate) - // agree on, on every platform (#58, #82). - env["TMPDIR"] = tempHost.path - // ShellKit-side URL gate paired with the real-disk - // `MountedFileSystem` above. Bash builtins resolve writes - // through the mount table (host workspace + the per-instance - // temp dir, mounted at both virtual `/tmp` and its host path); - // ShellKit-aware bridges (registered SwiftPorts CLIs, the - // SwiftScript interpreter) authorise the same paths via - // `Shell.sandbox`. Because both sides hit the same real-disk - // files for `$TMPDIR`-spelled paths, `mkdir -p "$TMPDIR/foo" - // && echo > "$TMPDIR/foo/x" && fd x "$TMPDIR/foo"` actually - // finds the file (#48 / #55 / #82). - let urlSandbox = ShellKit.Sandbox.bashWorkspace( - workspace: workspace, + // `$TMPDIR` carries the virtual `/tmp` spelling: the script + // level (env included) speaks virtual paths only, and the + // host path of the per-instance temp dir stays out of the + // sandbox (#82 / #83). FileManager-backed callers + // (SwiftPorts CLIs, the JS runtime, SwiftScript bridges) + // translate it back to the per-instance host dir through + // `Shell.resolve`, which consults the same mapping the + // mount table and the URL gate are built on. + env["TMPDIR"] = "/tmp" + // ShellKit-side URL gate built over the SAME mapping as the + // real-disk `MountedFileSystem` above — one core, two doors + // (#83). Bash builtins translate + confine through the mount + // table; ShellKit-aware bridges (registered SwiftPorts CLIs, + // the JS runtime, the SwiftScript interpreter) translate the + // same virtual paths via `Shell.resolve` and authorize the + // resulting host paths here. Both sides land on the same + // real-disk files, so `cd /tmp; mkdir foo; echo > foo/x; + // fd x foo` — and the same through `$TMPDIR` — finds the + // file (#48 / #55 / #82 / #83). + let urlSandbox = ShellKit.Sandbox.confined( + to: fileSystem.mapping, + home: workspace, temporaryDirectory: tempHost) return ShellSetup( environment: env, @@ -205,9 +208,14 @@ struct ExecCommand: AsyncParsableCommand { /// (the host workspace dir) appears at the virtual `workspace` /// mount; a freshly created `swiftbash-` dir under the /// platform's temp root (``Foundation.NSTemporaryDirectory()``) is - /// mounted at virtual `/tmp` and at its own host path so callers - /// using `$TMPDIR` agree with bash's `/tmp`-using scripts. All - /// three mounts are writable. + /// mounted at virtual `/tmp`. Both mounts are writable. + /// + /// The table is just workspace + `/tmp`: `$TMPDIR` carries the + /// virtual `/tmp` spelling and FileManager-backed callers + /// translate through `Shell.resolve`, so nothing hands + /// host-spelled temp paths to the bash side any more — the + /// pre-#83 identity mount (`tempHost` at its own host path) and + /// its Linux-nesting subtlety are gone. /// /// The per-instance dir is what keeps concurrent sandboxes (and /// the host's own temp files) out of each other's `/tmp` (#82). @@ -226,7 +234,7 @@ struct ExecCommand: AsyncParsableCommand { static func makeSandboxFileSystem( sandboxRoot: String, workspace: String - ) throws -> (fileSystem: FileSystem, tempHost: URL) { + ) throws -> (fileSystem: MountedFileSystem, tempHost: URL) { var isDir: ObjCBool = false guard FileManager.default.fileExists( atPath: sandboxRoot, isDirectory: &isDir), @@ -251,19 +259,12 @@ struct ExecCommand: AsyncParsableCommand { throw CLIError("--sandbox: could not create temp dir " + "\(tempHost.path): \(error.localizedDescription)") } - let mounts: [MountedFileSystem.Mount] = [ + let mapping = PathMapping(mounts: [ .init(virtual: workspace, host: sandboxRoot), - .init(virtual: "/tmp", host: tempHost.path), - // Expose the per-instance dir at its own host path too so - // `$TMPDIR/foo` and `/tmp/foo` resolve to the same files. - // On Linux the temp root IS `/tmp`, so this identity mount - // nests inside the `/tmp` mount above — longest-virtual- - // prefix routing makes the more specific entry win there, - // keeping both spellings on the same host files instead of - // double-nesting the instance dir. - .init(virtual: tempHost.path, host: tempHost.path) - ] - return (MountedFileSystem(mounts: mounts, backing: RealFileSystem()), + .init(virtual: "/tmp", host: tempHost.path) + ]) + return (MountedFileSystem(mapping: mapping, + backing: RealFileSystem()), tempHost) } diff --git a/Tests/BashInterpreterTests/BashWorkspaceSandboxTests.swift b/Tests/BashInterpreterTests/BashWorkspaceSandboxTests.swift deleted file mode 100644 index 70b913d3..00000000 --- a/Tests/BashInterpreterTests/BashWorkspaceSandboxTests.swift +++ /dev/null @@ -1,282 +0,0 @@ -import Testing -import Foundation -import ShellKit -@testable import BashInterpreter - -/// Coverage for `Sandbox.bashWorkspace(workspace:temporaryDirectory:)` — -/// the URL gate the SwiftBash `--sandbox` CLI pairs with the real-disk -/// ``MountedFileSystem`` mounting the workspace and a per-instance temp -/// dir. The gate accepts the virtual workspace mount point and the temp -/// dir in its host spelling (what `$TMPDIR` carries); it does NOT accept -/// the virtual `/tmp` spelling — callers of this gate do real I/O on the -/// path as given, so literal `/tmp` would reach the host's shared temp -/// dir, not this sandbox's. Regression cover for #48 / #55 / #58 / #82. -@Suite(.timeLimit(.minutes(1))) struct BashWorkspaceSandboxTests { - - /// Create (on disk) a per-instance temp dir shaped like the CLI's - /// `swiftbash-`. Caller removes it. - private static func makeInstanceTempDir() throws -> URL { - let dir = URL(fileURLWithPath: NSTemporaryDirectory(), - isDirectory: true) - .appendingPathComponent("swiftbash-gatetest-\(UUID().uuidString)", - isDirectory: true) - try FileManager.default.createDirectory( - at: dir, withIntermediateDirectories: true) - return dir - } - - @Test func authorizesWorkspaceRoot() async throws { - let sandbox = ShellKit.Sandbox.bashWorkspace(workspace: "/batch") - try await sandbox.authorize( - URL(fileURLWithPath: "/batch/file.txt")) - try await sandbox.authorize( - URL(fileURLWithPath: "/batch/nested/dir/file")) - } - - @Test func authorizesInstanceTempDir() async throws { - // The CLI passes its per-instance temp dir; the carve-out - // accepts the dir and anything under it, so `$TMPDIR`-spelled - // paths from SwiftPorts CLIs / SwiftScript authorize the same - // files the bash side writes through the mount (#48 / #82). - let tempDir = try Self.makeInstanceTempDir() - defer { try? FileManager.default.removeItem(at: tempDir) } - let sandbox = ShellKit.Sandbox.bashWorkspace( - workspace: "/batch", temporaryDirectory: tempDir) - try await sandbox.authorize(tempDir) - try await sandbox.authorize( - tempDir.appendingPathComponent("retest_fd_repro")) - try await sandbox.authorize( - tempDir.appendingPathComponent("retest_fd_repro") - .appendingPathComponent("data.txt")) - } - - @Test func deniesVirtualTmpSpelling() async throws { - // Virtual `/tmp` is bash's spelling, translated by the mount - // table. Callers of this gate carry no virtual→host translation - // (that lands with #83) — a literal `/tmp/...` would do real - // I/O on the host's shared `/tmp`, not this sandbox's temp dir, - // so the gate denies it (#82). - let tempDir = try Self.makeInstanceTempDir() - defer { try? FileManager.default.removeItem(at: tempDir) } - let sandbox = ShellKit.Sandbox.bashWorkspace( - workspace: "/batch", temporaryDirectory: tempDir) - await #expect(throws: ShellKit.Sandbox.Denial.self) { - try await sandbox.authorize(URL(fileURLWithPath: "/tmp")) - } - await #expect(throws: ShellKit.Sandbox.Denial.self) { - try await sandbox.authorize( - URL(fileURLWithPath: "/tmp/retest_fd_repro")) - } - } - - @Test func deniesSiblingInstanceTempDir() async throws { - // Two concurrent sandboxes get sibling `swiftbash-` dirs - // under the same platform temp root. Instance A's gate must not - // authorize instance B's dir — nor the shared root itself (#82). - let mine = try Self.makeInstanceTempDir() - let sibling = try Self.makeInstanceTempDir() - defer { - try? FileManager.default.removeItem(at: mine) - try? FileManager.default.removeItem(at: sibling) - } - let sandbox = ShellKit.Sandbox.bashWorkspace( - workspace: "/batch", temporaryDirectory: mine) - await #expect(throws: ShellKit.Sandbox.Denial.self) { - try await sandbox.authorize( - sibling.appendingPathComponent("leak.txt")) - } - await #expect(throws: ShellKit.Sandbox.Denial.self) { - try await sandbox.authorize( - URL(fileURLWithPath: NSTemporaryDirectory(), - isDirectory: true)) - } - } - - @Test func defaultGateAcceptsPlatformTempRoot() async throws { - // Without an explicit `temporaryDirectory:` the gate falls back - // to the platform temp root — shared with the whole process, - // but what bare API callers had before #82. The CLI always - // passes a per-instance dir instead. - let sandbox = ShellKit.Sandbox.bashWorkspace(workspace: "/batch") - let realTemp = NSTemporaryDirectory() - try await sandbox.authorize(URL(fileURLWithPath: realTemp)) - try await sandbox.authorize(URL(fileURLWithPath: - (realTemp as NSString).appendingPathComponent("probe.txt"))) - } - - @Test func deniesPathsOutsideBothRoots() async throws { - let sandbox = ShellKit.Sandbox.bashWorkspace(workspace: "/batch") - await #expect(throws: ShellKit.Sandbox.Denial.self) { - try await sandbox.authorize( - URL(fileURLWithPath: "/etc/passwd")) - } - await #expect(throws: ShellKit.Sandbox.Denial.self) { - try await sandbox.authorize( - URL(fileURLWithPath: "/Users/someone/Documents")) - } - } - - @Test func deniesPrefixSiblings() async throws { - // The classic prefix-collision bug: a path that merely starts - // with an accepted prefix's characters (no `/` boundary) must - // not pass. Cover both the temp dir and the workspace. - let tempDir = try Self.makeInstanceTempDir() - defer { try? FileManager.default.removeItem(at: tempDir) } - let sandbox = ShellKit.Sandbox.bashWorkspace( - workspace: "/batch", temporaryDirectory: tempDir) - await #expect(throws: ShellKit.Sandbox.Denial.self) { - try await sandbox.authorize( - URL(fileURLWithPath: tempDir.path + "extra")) - } - await #expect(throws: ShellKit.Sandbox.Denial.self) { - try await sandbox.authorize( - URL(fileURLWithPath: "/batchwork/foo")) - } - } - - @Test func deniesNonFileURLs() async throws { - // Non-file URLs go through the host allowlist (empty here). - let sandbox = ShellKit.Sandbox.bashWorkspace(workspace: "/batch") - await #expect(throws: ShellKit.Sandbox.Denial.self) { - try await sandbox.authorize( - URL(string: "https://example.com/")!) - } - } - - // The canonical re-check relies on `URL.resolvingSymlinksInPath()` - // to follow the symlink and re-evaluate the destination. swift- - // corelibs-foundation's Windows implementation doesn't traverse - // NTFS symlinks the way the Darwin/Glibc backends do — a planted - // symlink survives canonicalisation unchanged and the gate's - // second-pass check can't fire. Production code still depends on - // OS-level sandboxing on Windows (see Threat model in - // `Docs/Sandboxing.md`). -#if !os(Windows) - @Test func deniesTmpSymlinkEscape() async throws { - // Regression coverage for the #55 review concern: a bash-side - // `ln -s / "$TMPDIR/p"` plants a real symlink inside the temp - // dir whose *unresolved* path the carve-out would otherwise - // happily authorize — letting FileManager-backed bridges follow - // the link out of the sandbox. Aim the link at `/` so it - // resolves on every platform — `/etc/passwd` doesn't exist on - // the Android emulator and dangling-link resolution behaves - // differently across the libc backends. - let tempDir = try Self.makeInstanceTempDir() - defer { try? FileManager.default.removeItem(at: tempDir) } - let host = tempDir.appendingPathComponent("escape-link").path - try FileManager.default.createSymbolicLink( - atPath: host, withDestinationPath: "/") - - let sandbox = ShellKit.Sandbox.bashWorkspace( - workspace: "/batch", temporaryDirectory: tempDir) - await #expect(throws: ShellKit.Sandbox.Denial.self) { - try await sandbox.authorize(URL(fileURLWithPath: host)) - } - } -#endif - -#if !os(Windows) - @Test func acceptsSymlinkSpelledTempDir() async throws { - // An embedder may hand the gate a temp dir reached through a - // symlink and put that spelling in `$TMPDIR`. The carve-out - // must keep the verbatim spelling — `standardizingPath` - // rewrites symlinked components on corelibs-foundation, which - // would otherwise drop the only spelling callers actually use. - let real = try Self.makeInstanceTempDir() - defer { try? FileManager.default.removeItem(at: real) } - let linkPath = (NSTemporaryDirectory() as NSString) - .appendingPathComponent( - "swiftbash-gatelink-\(UUID().uuidString)") - try FileManager.default.createSymbolicLink( - atPath: linkPath, withDestinationPath: real.path) - defer { try? FileManager.default.removeItem(atPath: linkPath) } - - let linkURL = URL(fileURLWithPath: linkPath, isDirectory: true) - let sandbox = ShellKit.Sandbox.bashWorkspace( - workspace: "/batch", temporaryDirectory: linkURL) - try await sandbox.authorize(linkURL.appendingPathComponent("f")) - } -#endif - - @Test func allowsLegitimateTmpFilesAfterSymlinkResolution() async throws { - // The canonical re-check must not regress the legitimate case - // where a real file exists under the temp dir (which on macOS - // symlink-resolves through `/private/var/folders/…` — both - // spellings stay authorized). - let tempDir = try Self.makeInstanceTempDir() - defer { try? FileManager.default.removeItem(at: tempDir) } - let path = tempDir.appendingPathComponent("legit.txt").path - FileManager.default.createFile(atPath: path, contents: Data("hi".utf8)) - - let sandbox = ShellKit.Sandbox.bashWorkspace( - workspace: "/batch", temporaryDirectory: tempDir) - try await sandbox.authorize(URL(fileURLWithPath: path)) - } - - @Test func temporaryDirectoryIsRealTempPath() { - // `Shell.temporaryDirectory` reads `sandbox.temporaryDirectory`. - // Without an explicit override the gate reports the platform - // temp root; the CLI overrides with its per-instance dir so - // SwiftJSCore's `os.tmpdir()` and similar consumers return the - // same path `$TMPDIR` carries. - let sandbox = ShellKit.Sandbox.bashWorkspace(workspace: "/batch") - let expected = URL(fileURLWithPath: NSTemporaryDirectory(), - isDirectory: true).standardizedFileURL.path - let actual = sandbox.temporaryDirectory.standardizedFileURL.path - #expect(actual == expected) - } - - @Test func temporaryDirectoryOverrideIsReported() async throws { - // An embedder (e.g. iBash, isolating /tmp per document) passes - // its own temp dir; the gate reports it, and a file URL under it - // is still authorized by the carve-out. - let custom = URL(fileURLWithPath: NSTemporaryDirectory(), - isDirectory: true) - .appendingPathComponent("custom-\(UUID().uuidString)", - isDirectory: true) - let sandbox = ShellKit.Sandbox.bashWorkspace( - workspace: "/batch", temporaryDirectory: custom) - #expect(sandbox.temporaryDirectory.standardizedFileURL.path - == custom.standardizedFileURL.path) - try await sandbox.authorize(custom.appendingPathComponent("f")) - } - - @Test func authorizeNetworkRoutesNonFileURLs() async throws { - let recorder = URLRecorder() - let tempDir = try Self.makeInstanceTempDir() - defer { try? FileManager.default.removeItem(at: tempDir) } - let sandbox = ShellKit.Sandbox.bashWorkspace( - workspace: "/batch", temporaryDirectory: tempDir) { url in - await recorder.record(url) - } - // Non-file URL → the embedder's closure handles it. - try await sandbox.authorize(URL(string: "https://api.example.com/x")!) - #expect(await recorder.hosts == ["api.example.com"]) - // File URLs keep using the workspace + temp carve-out, never the - // network closure. - try await sandbox.authorize(URL(fileURLWithPath: "/batch/f")) - try await sandbox.authorize(tempDir.appendingPathComponent("f")) - #expect(await recorder.hosts.count == 1) - } - - @Test func authorizeNetworkCanDeny() async throws { - struct Blocked: Error {} - let tempDir = try Self.makeInstanceTempDir() - defer { try? FileManager.default.removeItem(at: tempDir) } - let sandbox = ShellKit.Sandbox.bashWorkspace( - workspace: "/batch", temporaryDirectory: tempDir) { _ in - throw Blocked() - } - await #expect(throws: Blocked.self) { - try await sandbox.authorize( - URL(string: "https://blocked.example/")!) - } - // …a temp-dir file URL is still allowed (carve-out unaffected). - try await sandbox.authorize(tempDir.appendingPathComponent("ok")) - } -} - -private actor URLRecorder { - private(set) var hosts: [String] = [] - func record(_ url: URL) { hosts.append(url.host ?? "") } -} diff --git a/Tests/BashInterpreterTests/ConfinedSandboxTests.swift b/Tests/BashInterpreterTests/ConfinedSandboxTests.swift new file mode 100644 index 00000000..536b6d84 --- /dev/null +++ b/Tests/BashInterpreterTests/ConfinedSandboxTests.swift @@ -0,0 +1,282 @@ +import Testing +import Foundation +@testable import BashInterpreter + +/// The #83 contract at the interpreter level: one ``PathMapping`` +/// drives BOTH enforcement facades — the bash-side +/// ``MountedFileSystem`` (Facade A) and the ShellKit pair +/// `Shell.resolve` + `Sandbox.confined(to:)` that FileManager-backed +/// callers use (Facade B). A path must be translated and confined +/// identically no matter which door it arrives through, `$TMPDIR` +/// carries the virtual `/tmp` spelling, and host paths fold back to +/// virtual on the way out. +/// +/// (Gate mechanics in isolation — prefix collisions, network policy, +/// region derivation — are pinned by ShellKit's `PathMappingTests`; +/// this suite covers the wiring SwiftBash layers on top. It replaces +/// the `BashWorkspaceSandboxTests` suite that pinned the retired +/// `Sandbox.bashWorkspace` carve-out gate.) +@Suite(.timeLimit(.minutes(1))) struct ConfinedSandboxTests { + + /// Workspace + per-instance temp dir on real disk, the mapping + /// over them, and a bash `Shell` wired the way `swift-bash exec + /// --sandbox` does it. Caller removes both dirs. + private struct Fixture { + let workspace: URL + let temp: URL + let mapping: PathMapping + let shell: Shell + + func cleanup() { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + } + + private static func makeFixture() throws -> Fixture { + let base = URL(fileURLWithPath: NSTemporaryDirectory(), + isDirectory: true) + let workspace = base.appendingPathComponent( + "confined-ws-\(UUID().uuidString)", isDirectory: true) + let temp = base.appendingPathComponent( + "swiftbash-confined-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: workspace, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: temp, withIntermediateDirectories: true) + let mapping = PathMapping(mounts: [ + .init(virtual: "/batch", host: workspace.path), + .init(virtual: "/tmp", host: temp.path) + ]) + var env = Environment() + env.workingDirectory = "/batch" + env.variables["HOME"] = "/batch" + env.variables["TMPDIR"] = "/tmp" + let shell = Shell( + fileSystem: MountedFileSystem(mapping: mapping, + backing: RealFileSystem()), + environment: env) + shell.sandbox = .confined(to: mapping, + home: "/batch", + temporaryDirectory: temp) + return Fixture(workspace: workspace, temp: temp, + mapping: mapping, shell: shell) + } + + // MARK: - Two doors, same files + + @Test func bashWritesAreReadableThroughResolve() async throws { + let fixture = try Self.makeFixture() + defer { fixture.cleanup() } + + // Facade A: a bash builtin writes through the mount table. + try await fixture.shell.fileSystem.createDirectory( + "/tmp/probe", intermediates: true) + try await fixture.shell.fileSystem.writeData( + Data("scratch\n".utf8), to: "/tmp/probe/data.txt", + append: false) + + // Facade B: the same virtual spelling, resolved + authorized + // + read with Foundation — the fd/rg/jq path (#48 / #55 / #83). + let resolved = fixture.shell.resolve("/tmp/probe/data.txt") + #expect(resolved.path == fixture.temp.path + "/probe/data.txt") + try await fixture.shell.sandbox?.authorize(resolved) + #expect(String(bytes: try Data(contentsOf: resolved), + encoding: .utf8) == "scratch\n") + } + + @Test func foundationWritesAreReadableThroughBash() async throws { + let fixture = try Self.makeFixture() + defer { fixture.cleanup() } + + // Facade B writes (a SwiftPorts CLI creating workspace + // output)… + let resolved = fixture.shell.resolve("/batch/report.txt") + #expect(resolved.path == fixture.workspace.path + "/report.txt") + try await fixture.shell.sandbox?.authorize(resolved) + try Data("report\n".utf8).write(to: resolved) + + // …and Facade A reads the identical file back via the + // virtual spelling. + let bytes = try await fixture.shell.fileSystem.readData( + "/batch/report.txt") + #expect(String(bytes: bytes, encoding: .utf8) == "report\n") + } + + @Test func tmpdirEnvSpellingTranslatesForFacadeB() async throws { + // `$TMPDIR` is the virtual `/tmp` now (#82 → #83): scripts + // pass it to FileManager-backed CLIs, which translate it + // through the bound shell rather than doing raw I/O on the + // literal spelling. + let fixture = try Self.makeFixture() + defer { fixture.cleanup() } + let tmpdir = fixture.shell.environment.variables["TMPDIR"] + #expect(tmpdir == "/tmp") + + try await fixture.shell.withCurrent { + let resolved = ShellKit.Shell.resolve(tmpdir! + "/via-env.txt") + #expect(resolved.path == fixture.temp.path + "/via-env.txt") + // Relative paths resolve against the virtual CWD and + // translate to the workspace host dir. + #expect(ShellKit.Shell.resolve("rel.txt").path + == fixture.workspace.path + "/rel.txt") + // The I/O-facing CWD is the workspace's host dir; the + // script-visible one stays virtual. + #expect(ShellKit.Shell.currentDirectory.path + == fixture.workspace.path) + #expect(ShellKit.Shell.current.environment.workingDirectory + == "/batch") + } + } + + @Test func mktempAnswerRoundTripsThroughResolve() async throws { + // `jq "$(mktemp)"`: mktemp hands the script a VIRTUAL path; + // the consuming CLI must land on the same host file bash + // created. + let fixture = try Self.makeFixture() + defer { fixture.cleanup() } + + let virtualPath = try await fixture.shell.fileSystem + .makeTempPath(prefix: "roundtrip") + #expect(virtualPath.hasPrefix("/tmp/")) + try await fixture.shell.fileSystem.writeData( + Data("tmpfile\n".utf8), to: virtualPath, append: false) + + let resolved = fixture.shell.resolve(virtualPath) + #expect(resolved.path.hasPrefix(fixture.temp.path + "/")) + try await fixture.shell.sandbox?.authorize(resolved) + #expect(String(bytes: try Data(contentsOf: resolved), + encoding: .utf8) == "tmpfile\n") + } + + // MARK: - Outbound: host paths fold back to virtual + + @Test func displayPathFoldsHostPathsBackToVirtual() async throws { + let fixture = try Self.makeFixture() + defer { fixture.cleanup() } + + // resolve → displayPath is the identity on virtual spellings + // (`realpath` hygiene for Facade B output like fd's + // --absolute-path). + let resolved = fixture.shell.resolve("/tmp/show/me.txt") + #expect(fixture.shell.displayPath(for: resolved) + == "/tmp/show/me.txt") + #expect(fixture.shell.displayPath( + for: fixture.workspace.path + "/out.txt") + == "/batch/out.txt") + // The sandbox's host-spelled temp region folds to `/tmp` — + // what `os.tmpdir()`-style consumers report. + let tempRegion = fixture.shell.sandbox!.temporaryDirectory + #expect(fixture.shell.displayPath(for: tempRegion) == "/tmp") + // Paths outside every mount display as given. + #expect(fixture.shell.displayPath(for: "/etc/passwd") + == "/etc/passwd") + } + + // MARK: - Same boundary at both doors + + @Test func gateDeniesVirtualSpellingsAndOutsidePaths() async throws { + // The gate authorizes HOST space only — `Shell.resolve` output. + // A literal virtual spelling reaching it (a caller that + // skipped translation) would mean raw I/O on the host's + // shared `/tmp` / nonexistent `/batch`, so it must deny. + let fixture = try Self.makeFixture() + defer { fixture.cleanup() } + let sandbox = fixture.shell.sandbox! + + await #expect(throws: ShellKit.Sandbox.Denial.self) { + try await sandbox.authorize(URL(fileURLWithPath: "/tmp/leak")) + } + await #expect(throws: ShellKit.Sandbox.Denial.self) { + try await sandbox.authorize(URL(fileURLWithPath: "/batch/f")) + } + await #expect(throws: ShellKit.Sandbox.Denial.self) { + try await sandbox.authorize(URL(fileURLWithPath: "/etc/passwd")) + } + // Sibling instances stay isolated (#82): another sandbox's + // temp dir — same platform root — never authorizes. + let sibling = try Self.makeFixture() + defer { sibling.cleanup() } + await #expect(throws: ShellKit.Sandbox.Denial.self) { + try await sandbox.authorize( + sibling.temp.appendingPathComponent("other.txt")) + } + await #expect(throws: ShellKit.Sandbox.Denial.self) { + try await sandbox.authorize( + URL(fileURLWithPath: NSTemporaryDirectory(), + isDirectory: true)) + } + } + + @Test func hostSpellingsAreUnaddressableAtBothDoors() async throws { + // Namespace discipline (Codex review on ShellKit#17): a + // script holding the HOST path of a mounted dir must not be + // able to address files through it. Facade A already ENOENTs + // it (no mount matches); Facade B's resolve voids it so the + // gate denies — the two doors agree. + // + // This test's mounts deliberately avoid virtual `/tmp`: on + // Linux the platform temp root IS `/tmp`, so the host + // spelling of a dir under it would prefix-match a virtual + // `/tmp` mount and translate as an ordinary virtual path — + // landing inside the mount's own backing (harmless, but not + // the no-mount-matches contract under test). + let fixture = try Self.makeFixture() + defer { fixture.cleanup() } + let mapping = PathMapping(mounts: [ + .init(virtual: "/work", host: fixture.workspace.path), + .init(virtual: "/scratch", host: fixture.temp.path) + ]) + var env = Environment() + env.workingDirectory = "/work" + let shell = Shell( + fileSystem: MountedFileSystem(mapping: mapping, + backing: RealFileSystem()), + environment: env) + shell.sandbox = .confined(to: mapping, home: "/work") + try await shell.fileSystem.writeData( + Data("secret\n".utf8), to: "/work/secret.txt", append: false) + let hostSpelling = fixture.workspace.path + "/secret.txt" + + // Facade A: not part of the virtual namespace. + #expect(try await shell.fileSystem + .metadata(hostSpelling) == nil) + + // Facade B: voided by resolve, denied by the gate, and the + // voided location cannot exist on disk. + let resolved = shell.resolve(hostSpelling) + #expect(!FileManager.default.fileExists(atPath: resolved.path)) + await #expect(throws: ShellKit.Sandbox.Denial.self) { + try await shell.sandbox!.authorize(resolved) + } + } + +#if !os(Windows) + @Test func symlinkEscapeIsRejectedAtBothDoors() async throws { + // A symlink planted inside the temp dir pointing outside the + // sandbox: Facade A reads it back as ENOENT (canonical gate), + // and Facade B's authorize denies the resolved host path — + // identical confinement, one boundary (#55 / #82 / #83). + let fixture = try Self.makeFixture() + defer { fixture.cleanup() } + let linkHost = fixture.temp.appendingPathComponent("escape-link") + try FileManager.default.createSymbolicLink( + atPath: linkHost.path, withDestinationPath: "/") + + // Facade A: the virtual spelling reports not-found. + #expect(try await fixture.shell.fileSystem + .metadata("/tmp/escape-link") == nil) + await #expect(throws: FileSystemError.self) { + _ = try await fixture.shell.fileSystem + .readData("/tmp/escape-link") + } + + // Facade B: the translated host path is denied by the gate. + let resolved = fixture.shell.resolve("/tmp/escape-link") + #expect(resolved.path == linkHost.path) + await #expect(throws: ShellKit.Sandbox.Denial.self) { + try await fixture.shell.sandbox!.authorize(resolved) + } + } +#endif +} diff --git a/Tests/SwiftBashTests/ExecCommandFileSystemTests.swift b/Tests/SwiftBashTests/ExecCommandFileSystemTests.swift index b73a6eb3..dd4a4b6b 100644 --- a/Tests/SwiftBashTests/ExecCommandFileSystemTests.swift +++ b/Tests/SwiftBashTests/ExecCommandFileSystemTests.swift @@ -94,12 +94,15 @@ import BashInterpreter #expect(String(bytes: backA, encoding: .utf8) == "A's secret\n") } - @Test func tmpSpellingsReachTheSameFiles() async throws { - // `$TMPDIR` carries the instance dir's host path; the mount - // table exposes that dir at virtual `/tmp` AND at its own host - // path. Both spellings must hit the same files — including on - // Linux, where the host path nests inside the `/tmp` mount and - // longest-prefix routing has to keep it from double-nesting. + @Test func bothFacadesReachTheSameTmpFiles() async throws { + // `$TMPDIR` carries the virtual `/tmp` spelling now (#83): + // bash translates it through the mount table (Facade A), and + // FileManager-backed callers translate the SAME spelling + // through `Shell.resolve` + the sandbox's path mapping + // (Facade B). Both must land on this instance's own temp dir + // — the pre-#83 identity mount (host path mounted at itself) + // is gone, so the host spelling no longer routes through the + // mount table at all. let host = try Self.makeScratchDir() defer { try? FileManager.default.removeItem(at: host) } @@ -109,9 +112,26 @@ import BashInterpreter try await fileSystem.writeData( Data("agree\n".utf8), to: "/tmp/agree.txt", append: false) - let viaHostSpelling = try await fileSystem.readData( - tempHost.appendingPathComponent("agree.txt").path) - #expect(String(bytes: viaHostSpelling, encoding: .utf8) == "agree\n") + + // Facade B: resolve the virtual spelling on a shell carrying + // the same mapping, authorize it, and read with Foundation. + let shell = Shell(fileSystem: fileSystem) + shell.sandbox = .confined(to: fileSystem.mapping, + home: "/batch", + temporaryDirectory: tempHost) + let resolved = shell.resolve("/tmp/agree.txt") + #expect(resolved.path + == tempHost.appendingPathComponent("agree.txt").path) + try await shell.sandbox?.authorize(resolved) + let viaFoundation = try Data(contentsOf: resolved) + #expect(String(bytes: viaFoundation, encoding: .utf8) == "agree\n") + + // The identity mount is retired: the mount table is just + // workspace + /tmp, and the host spelling doesn't resolve + // through the bash-side FS any more. + #expect(fileSystem.mountList.map(\.virtual).sorted() + == ["/batch", "/tmp"]) + #expect(try await fileSystem.metadata(tempHost.path) == nil) } @Test func pathsOutsideMountsAreMissing() async throws { diff --git a/Tests/SwiftJSCoreTests/SandboxGatePathMappingTests.swift b/Tests/SwiftJSCoreTests/SandboxGatePathMappingTests.swift new file mode 100644 index 00000000..2667ad1b --- /dev/null +++ b/Tests/SwiftJSCoreTests/SandboxGatePathMappingTests.swift @@ -0,0 +1,223 @@ +import XCTest +@testable import SwiftJSCore +import BashInterpreter +import BashCommandKit + +#if !os(Windows) // SwiftJSCore links the JSC C API everywhere except Windows for now + +/// The #83 contract for the JS runtime: under a shell whose sandbox +/// carries a `PathMapping` (the SwiftBash `--sandbox` shape), every +/// `fs.*` / `require` / `process.chdir` resolves the script's VIRTUAL +/// spelling to the mapped HOST directory for both the gate and the +/// I/O — while everything the script gets to *see* (`process.cwd()`, +/// `os.tmpdir()`, `__filename`) stays in the virtual spelling. +final class SandboxGatePathMappingTests: XCTestCase { + + /// Workspace + per-instance temp dir, mapping, and the `.confined` + /// sandbox over them — the same wiring `swift-bash exec --sandbox` + /// installs. Caller removes both dirs. + private struct MappedFixture { + let workspace: URL + let temp: URL + let sandbox: Sandbox + + func cleanup() { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + } + + private func makeMappedSandbox() -> MappedFixture { + let base = FileManager.default.temporaryDirectory + let workspace = base.appendingPathComponent( + "swiftjs-ws-\(UUID().uuidString)", isDirectory: true) + let temp = base.appendingPathComponent( + "swiftbash-js-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory( + at: workspace, withIntermediateDirectories: true) + try? FileManager.default.createDirectory( + at: temp, withIntermediateDirectories: true) + let mapping = PathMapping(mounts: [ + .init(virtual: "/batch", host: workspace.path), + .init(virtual: "/tmp", host: temp.path) + ]) + let sandbox = Sandbox.confined(to: mapping, + home: "/batch", + temporaryDirectory: temp) + return MappedFixture(workspace: workspace, temp: temp, + sandbox: sandbox) + } + + func testFsWritesVirtualTmpIntoInstanceTempDir() async throws { + let runtime = SandboxGateTestSupport.makeRuntime().runtime + let fixture = makeMappedSandbox() + defer { fixture.cleanup() } + + await SandboxGateTestSupport.withSandboxedShell( + sandbox: fixture.sandbox, cwd: "/batch") { + let result = runtime.run(#""" + const fs = require('fs'); + fs.writeFileSync('/tmp/probe.txt', 'scratch'); + `${fs.existsSync('/tmp/probe.txt')}|` + + fs.readFileSync('/tmp/probe.txt', 'utf-8'); + """#) + XCTAssertEqual(result?.toString(), "true|scratch") + } + + // The bytes landed in THIS instance's temp dir — not the + // host's shared `/tmp` (#82). + let hostFile = fixture.temp.appendingPathComponent("probe.txt") + XCTAssertEqual( + try String(contentsOf: hostFile, encoding: .utf8), "scratch") + XCTAssertFalse(FileManager.default.fileExists( + atPath: "/tmp/probe.txt")) + } + + func testRelativePathsResolveAgainstVirtualCwd() async throws { + let runtime = SandboxGateTestSupport.makeRuntime().runtime + let fixture = makeMappedSandbox() + defer { fixture.cleanup() } + + await SandboxGateTestSupport.withSandboxedShell( + sandbox: fixture.sandbox, cwd: "/batch") { + _ = runtime.run(#""" + require('fs').writeFileSync('relative.txt', 'in-workspace'); + """#) + } + let hostFile = fixture.workspace + .appendingPathComponent("relative.txt") + XCTAssertEqual( + try String(contentsOf: hostFile, encoding: .utf8), + "in-workspace") + } + + func testChdirKeepsVirtualCwdAndTranslatesIO() async throws { + let runtime = SandboxGateTestSupport.makeRuntime().runtime + let fixture = makeMappedSandbox() + defer { fixture.cleanup() } + + await SandboxGateTestSupport.withSandboxedShell( + sandbox: fixture.sandbox, cwd: "/batch") { + let result = runtime.run(#""" + process.chdir('/tmp'); + require('fs').writeFileSync('./after-chdir.txt', 'moved'); + process.cwd(); + """#) + // The script-visible cwd stays virtual… + XCTAssertEqual(result?.toString(), "/tmp") + } + // …while the write landed in the mapped host dir. + let hostFile = fixture.temp + .appendingPathComponent("after-chdir.txt") + XCTAssertEqual( + try String(contentsOf: hostFile, encoding: .utf8), "moved") + } + + func testOsAnswersStayVirtual() async { + let runtime = SandboxGateTestSupport.makeRuntime().runtime + let fixture = makeMappedSandbox() + defer { fixture.cleanup() } + + await SandboxGateTestSupport.withSandboxedShell( + sandbox: fixture.sandbox, + env: ["HOME": "/batch"], cwd: "/batch") { + let result = runtime.run(#""" + const os = require('os'); + `${os.tmpdir()}|${os.homedir()}`; + """#) + // Host per-instance paths fold back to the virtual + // spellings — nothing about the embedder's disk layout + // leaks into the script. + XCTAssertEqual(result?.toString(), "/tmp|/batch") + } + } + + func testPathsOutsideMountsAreDenied() async { + let runtime = SandboxGateTestSupport.makeRuntime().runtime + let fixture = makeMappedSandbox() + defer { fixture.cleanup() } + + await SandboxGateTestSupport.withSandboxedShell( + sandbox: fixture.sandbox, cwd: "/batch") { + let result = runtime.run(#""" + const fs = require('fs'); + let write; + try { + fs.writeFileSync('/etc/should-not-exist', 'x'); + write = 'no-throw'; + } catch (e) { write = e.code; } + `${write}|${fs.existsSync('/etc/passwd')}`; + """#) + XCTAssertEqual(result?.toString(), "EACCES|false") + } + } + + func testRequireLoadsModulesThroughTheMapping() async throws { + let runtime = SandboxGateTestSupport.makeRuntime().runtime + let fixture = makeMappedSandbox() + defer { fixture.cleanup() } + + // Seed a module on the host side of the workspace mount. + let module = fixture.workspace.appendingPathComponent("mod.js") + try Data(""" + module.exports = { tag: 'loaded', file: __filename }; + """.utf8).write(to: module) + + await SandboxGateTestSupport.withSandboxedShell( + sandbox: fixture.sandbox, cwd: "/batch") { + let result = runtime.run(#""" + const m = require('/batch/mod.js'); + `${m.tag}|${m.file}`; + """#) + // The module loads through the mapping, and __filename + // carries the VIRTUAL spelling. + XCTAssertEqual(result?.toString(), "loaded|/batch/mod.js") + } + } + + func testRequireSymlinkEscapeIsNotAnExistenceOracle() async throws { + // Codex P2 on #88: `require` probed `fileExists` on the + // translated host path BEFORE authorizing, so a workspace + // symlink escaping the sandbox let a script distinguish an + // existing outside target from a missing one by the error + // shape. The gate now runs first and a denied candidate folds + // into MODULE_NOT_FOUND — so both escapes look identical. + let runtime = SandboxGateTestSupport.makeRuntime().runtime + let fixture = makeMappedSandbox() + defer { fixture.cleanup() } + + // Two workspace symlinks: one to an existing outside file, + // one to a guaranteed-missing path. Both escape the mount. + let existingOutside = fixture.workspace + .appendingPathComponent("link-existing").path + try FileManager.default.createSymbolicLink( + atPath: existingOutside, withDestinationPath: "/etc/hosts") + let missingOutside = fixture.workspace + .appendingPathComponent("link-missing").path + try FileManager.default.createSymbolicLink( + atPath: missingOutside, + withDestinationPath: "/no/such/path-\(UUID().uuidString)") + + await SandboxGateTestSupport.withSandboxedShell( + sandbox: fixture.sandbox, cwd: "/batch") { + let probe = #""" + (name) => { + try { require(name); return 'loaded'; } + catch (e) { return e.code; } + } + """# + let tryRequire = runtime.run(probe)! + let existing = tryRequire.call( + withArguments: ["/batch/link-existing"]) + let missing = tryRequire.call( + withArguments: ["/batch/link-missing"]) + // Identical outcome — no oracle — and specifically the + // not-found shape, never EACCES that would confirm the + // escape reached an existing file. + XCTAssertEqual(existing?.toString(), "MODULE_NOT_FOUND") + XCTAssertEqual(missing?.toString(), "MODULE_NOT_FOUND") + } + } +} + +#endif