diff --git a/Sources/ShellKit/Environment/Environment.swift b/Sources/ShellKit/Environment/Environment.swift index 808a544..c28a878 100644 --- a/Sources/ShellKit/Environment/Environment.swift +++ b/Sources/ShellKit/Environment/Environment.swift @@ -67,7 +67,12 @@ public struct Environment: Hashable, Sendable { workingDirectory: String = "/" ) -> Environment { let vars: [String: String] = [ - "PATH": "/usr/bin:/bin", + // All three virtual bin tiers. `/usr/local/bin` is where + // embedders catalogue the "user-installed" command set + // (SwiftBash slots the SwiftPorts CLIs — fd, rg, … — + // there); omitting it made those unreachable in exactly + // the synthetic-env sandbox runs they exist for. + "PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": "/home/\(hostInfo.userName)", "USER": hostInfo.userName, "LOGNAME": hostInfo.userName, diff --git a/Sources/ShellKit/Sandbox/PathMapping.swift b/Sources/ShellKit/Sandbox/PathMapping.swift new file mode 100644 index 0000000..3f1b3f5 --- /dev/null +++ b/Sources/ShellKit/Sandbox/PathMapping.swift @@ -0,0 +1,190 @@ +import Foundation + +/// The virtual↔host path mapping a cooperative sandbox is built on: +/// a mount table translating script-visible *virtual* paths (`/`, +/// `/tmp`, `/batch`, …) to the *host* directories that back them. +/// +/// This is the single authority both enforcement facades share: +/// +/// - **Facade A — a `FileSystem` protocol** (SwiftBash's builtins and +/// any pure-Swift command written against it) routes every +/// operation through the table and confines symlink traversal to +/// each mount's host root. +/// - **Facade B — ``Shell/resolve(_:)``** (SwiftPorts CLIs, the JS +/// runtime, SwiftScript, and other Foundation/C-backed code) +/// translates a virtual path to its host spelling once, up front, +/// and then does real I/O; ``Sandbox/authorize(_:)`` re-checks the +/// same host path against the same mount roots. +/// +/// One core, two doors: a path is translated and confined +/// identically no matter which facade it arrives through. Keeping +/// the table here (rather than inside one consumer's filesystem +/// layer) is what stops the two sides from disagreeing about where +/// `/tmp/foo` lives. +/// +/// Translation is **purely lexical**: `.` / `..` are collapsed as +/// text before routing (so `..` cannot escape a mount), and no +/// symlinks are consulted. Symlink-escape *confinement* — verifying +/// that the canonical host path still lands inside the mount's host +/// root — is layered on top by the consumers that touch the disk: +/// ``Sandbox/confined(to:home:temporaryDirectory:allowedHosts:authorizeNetwork:)`` +/// for Facade B, and the embedder's mounted filesystem for Facade A. +/// +/// Mount precedence is "longest virtual prefix wins" — `/tmp/foo` +/// matches a `/tmp` mount before a `/` mount. +public struct PathMapping: Sendable { + + /// One mount-table entry: an absolute virtual prefix backed by an + /// absolute host directory. + 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, the mount is advertised as read-only. The mapping + /// itself doesn't enforce this (translation has no read/write + /// intent); filesystem layers consult it to reject writes. + public var readOnly: Bool + + public init(virtual: String, host: String, readOnly: Bool = false) { + // Normalise lexically: strip `.` / `..` / trailing `/` so + // `/tmp` and `/tmp/` compare equal. Deliberately NOT + // `NSString.standardizingPath`, which consults the host + // disk and resolves symlinks on corelibs-foundation — + // the virtual side must never depend on host disk layout. + self.virtual = Shell.normalizePath(virtual) + self.host = (host as NSString).standardizingPath + self.readOnly = readOnly + } + } + + /// Mount table, longest virtual prefix first so the most specific + /// mount wins during translation. + public let mounts: [Mount] + + public init(mounts: [Mount]) { + self.mounts = mounts.sorted { $0.virtual.count > $1.virtual.count } + } + + /// 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 } + } + + // MARK: - Virtual → host + + /// Translate an absolute virtual path to the host path that backs + /// it, or `nil` when no mount matches (the path doesn't exist as + /// far as the sandbox is concerned) or `path` isn't absolute. + /// + /// The path is normalised lexically (`/tmp/../home/foo` → + /// `/home/foo`) BEFORE routing, so `..` cannot cross out of a + /// mount unseen. Purely textual — no symlink resolution, no disk + /// access; confinement of what the host path *resolves to* is the + /// caller's second step. + public func hostPath(forVirtual path: String) -> (mount: Mount, host: String)? { + let std = Shell.normalizePath(path) + guard std.hasPrefix("/") else { return nil } + 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 + } + + // MARK: - Host → virtual + + /// Fold a host path back to its virtual spelling, or `nil` when + /// it doesn't fall under any mount's host root. + /// + /// This is the "no `realpath` leak" direction: anything a script + /// gets to *see* — `$TMPDIR`-derived answers, display output, + /// diagnostics carrying resolved paths — should travel through + /// here (via ``Shell/displayPath(for:)-swift.method``) so the + /// embedder's host directory layout stays out of the sandbox. + /// + /// Matching tolerates spelling variance on both sides: the + /// verbatim, lexically-normalised, and symlink-resolved forms of + /// the mount's host root and of `path` are all compared (macOS + /// reports temp paths behind the `/var` → `/private/var` link in + /// either spelling depending on which API produced them). The + /// longest matching host root wins, mirroring the + /// longest-virtual-prefix rule of ``hostPath(forVirtual:)``. + public func virtualPath(forHost path: String) -> String? { + // Best fold so far: the rebuilt virtual spelling plus the + // length of the host root that matched (longer root = more + // specific mount). + struct Fold { + let virtual: String + let matched: Int + } + let candidates = Self.spellings(of: path) + var best: Fold? + for mount in mounts { + for root in Self.spellings(of: mount.host) { + for candidate in candidates { + let rel: String + if candidate == root { + rel = "" + } else if candidate.hasPrefix(root + "/") { + rel = String(candidate.dropFirst(root.count + 1)) + } else { + continue + } + if let current = best, root.count <= current.matched { + continue + } + let folded: String + if rel.isEmpty { + folded = mount.virtual + } else if mount.virtual == "/" { + folded = "/" + rel + } else { + folded = mount.virtual + "/" + rel + } + best = Fold(virtual: folded, matched: root.count) + } + } + } + return best?.virtual + } + + /// The spellings under which a host path might be reported: + /// verbatim (minus a trailing `/`), lexically normalised, and + /// symlink-resolved. `$TMPDIR`-style values keep the embedder's + /// verbatim spelling while `FileManager` answers carry the + /// canonical one; both must fold. + private static func spellings(of path: String) -> [String] { + var stripped = path + if stripped.count > 1, stripped.hasSuffix("/") { + stripped.removeLast() + } + var result: [String] = [] + for candidate in [ + stripped, + Shell.normalizePath(stripped), + URL(fileURLWithPath: stripped).resolvingSymlinksInPath().path + ] where !result.contains(candidate) { + result.append(candidate) + } + return result + } +} diff --git a/Sources/ShellKit/Sandbox/Sandbox+Confined.swift b/Sources/ShellKit/Sandbox/Sandbox+Confined.swift new file mode 100644 index 0000000..ee566af --- /dev/null +++ b/Sources/ShellKit/Sandbox/Sandbox+Confined.swift @@ -0,0 +1,113 @@ +import Foundation + +extension Sandbox { + + // MARK: - confined(to:) + + /// Confinement over a virtual↔host ``PathMapping``: the gate + /// authorizes a file URL iff its canonical (symlink-resolved) + /// path lands inside one of the mapping's host roots. + /// + /// This is the Facade-B half of the cooperative sandbox. Callers + /// that do real Foundation/C I/O — SwiftPorts CLIs, the JS + /// runtime, SwiftScript — obtain host paths from + /// ``Shell/resolve(_:)`` (which translates virtual spellings + /// through this same mapping, because the sandbox carries it as + /// ``Sandbox/pathMapping``) and authorize them here. A bash-side + /// mounted filesystem built over the same mapping confines its + /// own traffic identically, so both doors agree on what `/tmp/x` + /// means and on where the boundary is. + /// + /// Symlink escapes are rejected by canonicalising both sides: a + /// link planted inside a mount (`ln -s /etc/passwd "$TMPDIR/p"`) + /// resolves outside every canonical host root and is denied. + /// Conversely, a mount whose host root is itself reached through + /// a symlink still authorizes — both the root and the candidate + /// resolve to the same canonical prefix. + /// + /// The mapping's `readOnly` flag is *not* enforced here — + /// `authorize(_:)` carries no read/write intent. Filesystem + /// layers that know the intent enforce it. + /// + /// - Parameters: + /// - mapping: the mount table to confine to. The sandbox keeps + /// it as ``Sandbox/pathMapping`` so ``Shell/resolve(_:)`` and + /// ``Shell/displayPath(for:)-swift.method`` translate through + /// the very table the gate enforces. + /// - home: virtual path of the mount that anchors the region + /// directories (`homeDirectory`, `documentsDirectory`, …). + /// The regions are host-spelled — consumers hand them to + /// Foundation — and live under that mount's host root. + /// - temporaryDirectory: host dir reported as + /// ``Sandbox/temporaryDirectory``. Defaults to the host root + /// of the mapping's `/tmp` mount; an embedder that doesn't + /// mount `/tmp` gets the platform temp dir *reported* but not + /// *authorized* (nothing outside the mounts ever is — temp + /// access is the embedder's choice, made by mounting it). + /// - allowedHosts: network hosts the default non-file gate + /// admits (deny-all when empty). + /// - authorizeNetwork: embedder override for non-file URLs + /// (e.g. routing host access through a permission prompt). + /// When `nil`, non-file URLs go through `allowedHosts`. + public static func confined( + to mapping: PathMapping, + home: String = "/", + temporaryDirectory: URL? = nil, + allowedHosts: [String] = [], + authorizeNetwork: (@Sendable (URL) async throws -> Void)? = nil + ) -> Sandbox { + // Region anchor: the host root backing the virtual `home`. + // (Falling back to the literal string only happens when the + // embedder anchors regions outside its own mapping — a + // misconfiguration that then fails closed at the gate.) + let hostHome = mapping.hostPath(forVirtual: home)?.host ?? home + let homeURL = URL(fileURLWithPath: hostHome, isDirectory: true) + let tmpURL = temporaryDirectory + ?? mapping.hostPath(forVirtual: "/tmp") + .map { URL(fileURLWithPath: $0.host, isDirectory: true) } + ?? FileManager.default.temporaryDirectory + // Canonicalise every mount's host root once, up front — same + // strategy as `rooted(at:)`. Roots exist by the time the + // sandbox is built (mounting a missing directory is the + // embedder's bug and fails closed: nothing canonicalises + // into a nonexistent root). + let canonicalRoots = mapping.mounts.map { + canonicalizePath($0.host) + ?? URL(fileURLWithPath: $0.host).standardizedFileURL.path + } + let allowedHostSet = Set(allowedHosts) + return Sandbox( + documentsDirectory: homeURL.appendingPathComponent("Documents", isDirectory: true), + downloadsDirectory: homeURL.appendingPathComponent("Downloads", isDirectory: true), + libraryDirectory: homeURL.appendingPathComponent("Library", isDirectory: true), + moviesDirectory: homeURL.appendingPathComponent("Movies", isDirectory: true), + musicDirectory: homeURL.appendingPathComponent("Music", isDirectory: true), + picturesDirectory: homeURL.appendingPathComponent("Pictures", isDirectory: true), + sharedPublicDirectory: homeURL.appendingPathComponent("Public", isDirectory: true), + temporaryDirectory: tmpURL, + trashDirectory: homeURL.appendingPathComponent(".Trash", isDirectory: true), + userDirectory: homeURL, + cachesDirectory: homeURL.appendingPathComponent("Library/Caches", isDirectory: true), + // The home mount IS the user's home — `cd` with no + // argument and `~` both land at the workspace. + homeDirectory: homeURL, + pathMapping: mapping, + authorize: { url in + guard url.isFileURL else { + if let authorizeNetwork { + try await authorizeNetwork(url) + return + } + try authorizeUnderRoots( + url: url, + canonicalRoots: [], + allowedHosts: allowedHostSet) + return + } + try authorizeUnderRoots( + url: url, + canonicalRoots: canonicalRoots, + allowedHosts: allowedHostSet) + }) + } +} diff --git a/Sources/ShellKit/Sandbox/Sandbox+Factories.swift b/Sources/ShellKit/Sandbox/Sandbox+Factories.swift index 9fa68a5..be89160 100644 --- a/Sources/ShellKit/Sandbox/Sandbox+Factories.swift +++ b/Sources/ShellKit/Sandbox/Sandbox+Factories.swift @@ -134,7 +134,10 @@ extension Sandbox { }) } - // MARK: - Authorization helpers (internal but file-scoped sendable) + // MARK: - Authorization helpers + // + // `authorizeUnderRoots` is internal (not fileprivate): the + // path-mapping gate in `Sandbox+Confined.swift` shares it. fileprivate static func authorizeUnderRoot( url: URL, @@ -147,7 +150,7 @@ extension Sandbox { allowedHosts: allowedHosts) } - fileprivate static func authorizeUnderRoots( + static func authorizeUnderRoots( url: URL, canonicalRoots: [String], allowedHosts: Set diff --git a/Sources/ShellKit/Sandbox/Sandbox.swift b/Sources/ShellKit/Sandbox/Sandbox.swift index f0fe258..63c81f6 100644 --- a/Sources/ShellKit/Sandbox/Sandbox.swift +++ b/Sources/ShellKit/Sandbox/Sandbox.swift @@ -58,6 +58,22 @@ public struct Sandbox: Sendable { /// User caches directory. public let cachesDirectory: URL + // MARK: - Path mapping + + /// The virtual↔host ``PathMapping`` this sandbox confines to, when + /// it was built over one (``confined(to:home:temporaryDirectory:allowedHosts:authorizeNetwork:)``). + /// + /// When set, ``Shell/resolve(_:)`` translates virtual paths to + /// their host spelling through this table before returning, and + /// ``Shell/displayPath(for:)-swift.method`` folds host paths back + /// to virtual for display — the same table ``authorize(_:)`` + /// gates against, so resolution and confinement can't disagree. + /// `nil` for sandboxes without a virtual path space + /// (``rooted(at:allowedHosts:)``, ``appContainer(id:allowedHosts:)``): + /// there, paths mean the same thing on both sides and `resolve` + /// passes them through untranslated. + public let pathMapping: PathMapping? + // MARK: - URL gate private let _authorize: @Sendable (URL) async throws -> Void @@ -88,6 +104,7 @@ public struct Sandbox: Sendable { userDirectory: URL, cachesDirectory: URL, homeDirectory: URL? = nil, + pathMapping: PathMapping? = nil, authorize: @escaping @Sendable (URL) async throws -> Void ) { self.documentsDirectory = documentsDirectory @@ -102,6 +119,7 @@ public struct Sandbox: Sendable { self.userDirectory = userDirectory self.cachesDirectory = cachesDirectory self.homeDirectory = homeDirectory ?? documentsDirectory + self.pathMapping = pathMapping self._authorize = authorize } diff --git a/Sources/ShellKit/Shell+Path.swift b/Sources/ShellKit/Shell+Path.swift new file mode 100644 index 0000000..ccf277c --- /dev/null +++ b/Sources/ShellKit/Shell+Path.swift @@ -0,0 +1,251 @@ +import Foundation + +extension Shell { + + /// Lexical path normalisation — collapses `.` / `..` / repeated + /// `/` purely as text, never touching the filesystem. **Does not + /// resolve symlinks.** + /// + /// Replaces `NSString.standardizingPath`, which is technically + /// supposed to be lexical but on swift-corelibs-foundation + /// (Linux) follows symlinks too — making a purely textual + /// operation (`cd -L`, mount-table routing, virtual→host + /// translation) suddenly depend on what happens to exist on the + /// host disk. ``PathMapping`` routes every virtual path through + /// this before consulting its mount table, so `..` cannot escape + /// a mount and macOS autofs symlinks (`/home` → + /// `/System/Volumes/Data/home`) cannot hijack the routing. + /// + /// 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`. + public 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: "/") + } +} + +extension Shell { + + // MARK: - Path resolution + + /// Resolve a (possibly relative) path string into the absolute + /// `URL` to do real I/O on, honouring the shell's current working + /// directory and — under a path-mapped sandbox — translating the + /// virtual spelling to the host directory that backs it. + /// + /// Relative paths resolve against ``environment``'s + /// ``Environment/workingDirectory``. When the bound sandbox + /// carries a ``Sandbox/pathMapping`` (the cooperative-sandbox + /// case: the script-visible path space is virtual), the resolved + /// path is then routed through the mount table, so `/tmp/x` + /// comes back as the per-instance host temp dir's `x` — ready + /// for `FileManager` / C APIs and for ``Sandbox/authorize(_:)``, + /// which gates the same host space. Without a mapping the result + /// is the path as given (absolute) or CWD-joined (relative), + /// unchanged from how it always worked. + /// + /// The returned URL is for *doing I/O*, not for *showing to the + /// script* — display output should keep the user's own spelling + /// or fold a host path back through + /// ``displayPath(for:)-swift.method`` so the embedder's host + /// layout never leaks into the sandbox. + /// + /// Under a mapping, a path that matches **no** mount — `/etc/x`, + /// or the *host* spelling of a mounted directory (guessed, or + /// leaked through some diagnostic) — never comes back as given: + /// it resolves to a voided location under + /// ``unmappedPathSentinel`` that cannot exist on disk and that + /// ``Sandbox/authorize(_:)`` rejects. Script-visible text is the + /// only addressing scheme inside the virtual namespace; if host + /// spellings passed through here, anyone holding the host path + /// of a mount could sidestep the no-host-paths boundary (the + /// containment boundary would hold, the namespace one wouldn't — + /// and bash's mounted filesystem, which ENOENTs those spellings, + /// would disagree with this facade). + /// + /// CLI commands resolving relative argv paths should use this + /// instead of `URL(fileURLWithPath:)` / `FileManager.default.currentDirectoryPath` + /// directly so embedders can confine path resolution to whatever + /// CWD they bound (typically tracked via the script's own `cd` + /// builtin updating `environment.workingDirectory`). + /// + /// The static counterpart ``resolve(_:)`` is the convenient + /// call site for code that doesn't already have a `Shell` + /// reference; it routes through ``current``. + public func resolve(_ path: String) -> URL { + let url: URL + if path.hasPrefix("/") { + url = URL(fileURLWithPath: path) + } else { + #if os(Windows) + if path.count >= 2, + let second = path.dropFirst().first, second == ":" { + // Drive-letter absolutes live outside the unix-style + // virtual namespace; under a mapping they fall through + // to the unmapped sentinel below. + let driveURL = URL(fileURLWithPath: path) + guard sandbox?.pathMapping != nil else { return driveURL } + return Self.voidedUnmappedPath(path) + } + #endif + let cwd = environment.workingDirectory + if cwd.isEmpty { + // Host-process CWD fallback. Without a mapping this + // is the standalone-CLI behaviour. With one, an empty + // embedder CWD is a misconfiguration — the host-CWD + // join would produce exactly the kind of host-space + // path the namespace must not honour, so it voids. + let hostJoined = URL( + fileURLWithPath: FileManager.default.currentDirectoryPath, + isDirectory: true) + .appendingPathComponent(path) + guard let mapping = sandbox?.pathMapping else { + return hostJoined + } + if let translated = mapping.hostPath(forVirtual: hostJoined.path) { + return URL(fileURLWithPath: translated.host) + } + return Self.voidedUnmappedPath(hostJoined.path) + } + url = URL(fileURLWithPath: cwd, isDirectory: true) + .appendingPathComponent(path) + } + guard let mapping = sandbox?.pathMapping else { + // No mapping: the resolution is the path itself, exactly + // as it always worked. + return url + } + guard let translated = mapping.hostPath(forVirtual: url.path) else { + // Mapping present but no mount matches: the path does not + // exist in the virtual namespace. Void it — never hand + // back text that the host (or the gate) could interpret + // as a real location. + return Self.voidedUnmappedPath(url.path) + } + return URL(fileURLWithPath: translated.host) + } + + /// Resolve `path` against ``current``'s working directory. + /// Equivalent to `Shell.current.resolve(path)`; provided for + /// call-site convenience. + public static func resolve(_ path: String) -> URL { + current.resolve(path) + } + + /// Prefix under which ``resolve(_:)`` voids paths that exist in + /// no mount of the bound mapping. Rooted under `/dev/null` — a + /// *file* on every POSIX host — so nothing below it can exist or + /// be created (`mkdir -p` included), and no sane mount table + /// roots a host directory there; ``Sandbox/authorize(_:)``'s + /// containment check rejects it like any other out-of-roots path. + /// The original (virtual) spelling rides along as the suffix so a + /// stray diagnostic stays debuggable, and + /// ``displayPath(for:)-swift.method`` folds it back out. + public static let unmappedPathSentinel = "/dev/null/unmapped" + + /// Build the voided URL for an absolute path that matched no + /// mount. Lexically normalised first so the sentinel can't be + /// `..`-escaped back out of `/dev/null`. + private static func voidedUnmappedPath(_ path: String) -> URL { + var normalized = normalizePath(path) + if !normalized.hasPrefix("/") { normalized = "/" + normalized } + return URL(fileURLWithPath: unmappedPathSentinel + normalized) + } + + // MARK: - Display paths + + /// Fold a host filesystem path back to its virtual spelling for + /// display, when the bound sandbox carries a + /// ``Sandbox/pathMapping``. The inverse of ``resolve(_:)``: + /// `resolve` is for doing I/O, `displayPath` is for anything the + /// script gets to *see* — `realpath`-style answers, `--absolute-path` + /// output, `os.tmpdir()`, diagnostics that embed a resolved path. + /// Returns `path` unchanged when no mapping is bound or the path + /// doesn't fall under any mount (nothing to hide). + /// + /// Voided paths (``unmappedPathSentinel``) fold back to the + /// virtual spelling they were built from, so a diagnostic that + /// routes through here shows the script its own path, not the + /// sentinel. + public func displayPath(for path: String) -> String { + guard let mapping = sandbox?.pathMapping else { return path } + if path == Self.unmappedPathSentinel { + return "/" + } + if path.hasPrefix(Self.unmappedPathSentinel + "/") { + return String(path.dropFirst(Self.unmappedPathSentinel.count)) + } + guard let virtual = mapping.virtualPath(forHost: path) else { + return path + } + return virtual + } + + /// URL-form convenience for ``displayPath(for:)-swift.method``. + public func displayPath(for url: URL) -> String { + displayPath(for: url.path) + } + + /// Fold `path` through ``current``'s mapping for display. + public static func displayPath(for path: String) -> String { + current.displayPath(for: path) + } + + /// Fold `url` through ``current``'s mapping for display. + public static func displayPath(for url: URL) -> String { + current.displayPath(for: url) + } +} diff --git a/Sources/ShellKit/Shell+Static.swift b/Sources/ShellKit/Shell+Static.swift index ea0183d..1ff552b 100644 --- a/Sources/ShellKit/Shell+Static.swift +++ b/Sources/ShellKit/Shell+Static.swift @@ -36,14 +36,21 @@ public extension Shell { current.positionalParameters } - /// Current working directory. Honours - /// ``Shell/current``'s `environment.workingDirectory` when set, + /// Current working directory, as a URL ready for real I/O. + /// Honours ``Shell/current``'s `environment.workingDirectory` + /// when set — translated to its host spelling under a + /// path-mapped sandbox, exactly like ``Shell/resolve(_:)`` — /// otherwise falls back to the OS CWD via /// `FileManager.default.currentDirectoryPath`. + /// + /// For the *script-visible* CWD string (`pwd`, `process.cwd()`), + /// read `environment.workingDirectory` directly — that one stays + /// in the virtual spelling. static var currentDirectory: URL { let cwd = current.environment.workingDirectory if !cwd.isEmpty { - return URL(fileURLWithPath: cwd, isDirectory: true) + return URL(fileURLWithPath: current.resolve(cwd).path, + isDirectory: true) } return URL( fileURLWithPath: FileManager.default.currentDirectoryPath, diff --git a/Sources/ShellKit/Shell.swift b/Sources/ShellKit/Shell.swift index f575e7d..54c8bcf 100644 --- a/Sources/ShellKit/Shell.swift +++ b/Sources/ShellKit/Shell.swift @@ -265,51 +265,6 @@ open class Shell: @unchecked Sendable { return try await Shell.$current.withValue(self) { try await body() } } - // MARK: - Path resolution - - /// Resolve a (possibly relative) path string into an absolute - /// `URL`, honouring the shell's current working directory. - /// - /// Absolute paths (`/foo/bar`, or `C:\foo` on Windows) come back - /// unchanged. Relative paths resolve against - /// ``environment``'s ``Environment/workingDirectory``. - /// - /// CLI commands resolving relative argv paths should use this - /// instead of `URL(fileURLWithPath:)` / `FileManager.default.currentDirectoryPath` - /// directly so embedders can confine path resolution to whatever - /// CWD they bound (typically tracked via the script's own `cd` - /// builtin updating `environment.workingDirectory`). - /// - /// The static counterpart ``resolve(_:)`` is the convenient - /// call site for code that doesn't already have a `Shell` - /// reference; it routes through ``current``. - public func resolve(_ path: String) -> URL { - if path.hasPrefix("/") { - return URL(fileURLWithPath: path) - } - #if os(Windows) - if path.count >= 2, - let second = path.dropFirst().first, second == ":" { - return URL(fileURLWithPath: path) - } - #endif - let cwd = environment.workingDirectory - if cwd.isEmpty { - return URL( - fileURLWithPath: FileManager.default.currentDirectoryPath, - isDirectory: true) - .appendingPathComponent(path) - } - return URL(fileURLWithPath: cwd, isDirectory: true) - .appendingPathComponent(path) - } - - /// Resolve `path` against ``current``'s working directory. - /// Equivalent to `Shell.current.resolve(path)`; provided for - /// call-site convenience. - public static func resolve(_ path: String) -> URL { - current.resolve(path) - } } // MARK: - InputSource process standard input diff --git a/Tests/ShellKitTests/PathMappingTests.swift b/Tests/ShellKitTests/PathMappingTests.swift new file mode 100644 index 0000000..534b710 --- /dev/null +++ b/Tests/ShellKitTests/PathMappingTests.swift @@ -0,0 +1,281 @@ +import Foundation +import Testing +@testable import ShellKit + +/// The virtual↔host mapping core (#SwiftBash 83): lexical +/// translation through the mount table in both directions, plus the +/// two doors over it — `Shell.resolve` (virtual → host, for I/O) and +/// `Shell.displayPath` (host → virtual, for output) — and the +/// `Sandbox.confined(to:)` gate that authorizes the same host space +/// `resolve` produces. +@Suite(.timeLimit(.minutes(1))) struct PathMappingTests { + + // MARK: - Fixtures + + /// On-disk workspace + per-instance temp dir, shaped like the + /// SwiftBash CLI's `--sandbox ` setup. Caller removes both. + private static func makeRoots() throws -> (workspace: URL, temp: URL) { + let base = FileManager.default.temporaryDirectory + let workspace = base.appendingPathComponent( + "shellkit-ws-\(UUID().uuidString)", isDirectory: true) + let temp = base.appendingPathComponent( + "shellkit-tmp-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: workspace, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: temp, withIntermediateDirectories: true) + return (workspace, temp) + } + + private static func mapping(workspace: URL, temp: URL) -> PathMapping { + PathMapping(mounts: [ + .init(virtual: "/batch", host: workspace.path), + .init(virtual: "/tmp", host: temp.path) + ]) + } + + // MARK: - Virtual → host translation + + @Test func translatesUnderEachMount() throws { + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let mapping = Self.mapping(workspace: workspace, temp: temp) + + #expect(mapping.hostPath(forVirtual: "/batch/a/b.txt")?.host + == workspace.path + "/a/b.txt") + #expect(mapping.hostPath(forVirtual: "/tmp/x")?.host + == temp.path + "/x") + // The mount point itself maps to the host root. + #expect(mapping.hostPath(forVirtual: "/batch")?.host + == workspace.path) + #expect(mapping.hostPath(forVirtual: "/tmp/")?.host + == temp.path) + } + + @Test func longestVirtualPrefixWins() { + let mapping = PathMapping(mounts: [ + .init(virtual: "/", host: "/host/root"), + .init(virtual: "/tmp", host: "/host/tmp") + ]) + #expect(mapping.hostPath(forVirtual: "/tmp/f")?.host == "/host/tmp/f") + #expect(mapping.hostPath(forVirtual: "/tmpfoo")?.host + == "/host/root/tmpfoo") + #expect(mapping.hostPath(forVirtual: "/other")?.host + == "/host/root/other") + #expect(mapping.hostPath(forVirtual: "/")?.host == "/host/root") + } + + @Test func dotDotCollapsesBeforeRouting() { + let mapping = PathMapping(mounts: [ + .init(virtual: "/batch", host: "/host/ws"), + .init(virtual: "/tmp", host: "/host/tmp") + ]) + // `..` crossing from one mount into another routes to the + // OTHER mount — never to a host path outside both. + #expect(mapping.hostPath(forVirtual: "/tmp/../batch/f")?.host + == "/host/ws/f") + // `..` above the root stays at the root (no mount → nil). + #expect(mapping.hostPath(forVirtual: "/batch/../../etc") == nil) + } + + @Test func unmappedAndRelativePathsReturnNil() { + let mapping = PathMapping(mounts: [ + .init(virtual: "/batch", host: "/host/ws") + ]) + #expect(mapping.hostPath(forVirtual: "/etc/passwd") == nil) + #expect(mapping.hostPath(forVirtual: "/") == nil) + #expect(mapping.hostPath(forVirtual: "relative/path") == nil) + // Prefix sibling: `/batchwork` must not match `/batch`. + #expect(mapping.hostPath(forVirtual: "/batchwork/f") == nil) + } + + @Test func readOnlyFlagRidesAlong() { + let mapping = PathMapping(mounts: [ + .init(virtual: "/ro", host: "/host/ro", readOnly: true), + .init(virtual: "/rw", host: "/host/rw") + ]) + #expect(mapping.hostPath(forVirtual: "/ro/f")?.mount.readOnly == true) + #expect(mapping.hostPath(forVirtual: "/rw/f")?.mount.readOnly == false) + // Display order is by virtual path, not specificity. + #expect(mapping.mountList.map(\.virtual) == ["/ro", "/rw"]) + } + + // MARK: - Host → virtual folding + + @Test func foldsHostPathsBackToVirtual() throws { + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let mapping = Self.mapping(workspace: workspace, temp: temp) + + #expect(mapping.virtualPath(forHost: temp.path + "/probe/f.txt") + == "/tmp/probe/f.txt") + #expect(mapping.virtualPath(forHost: workspace.path) == "/batch") + // Symlink-resolved spelling folds too (macOS reports temp + // paths behind `/var` → `/private/var` in either form). + let resolved = temp.resolvingSymlinksInPath().path + #expect(mapping.virtualPath(forHost: resolved + "/f") == "/tmp/f") + // Outside every mount: nothing to fold. + #expect(mapping.virtualPath(forHost: "/etc/passwd") == nil) + } + + @Test func foldPrefersLongestHostRoot() throws { + // Nested host roots (the Linux case: per-instance temp dir + // under `/tmp` with `/tmp` itself also mounted) fold to the + // more specific mount. + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let nested = temp.appendingPathComponent("inner", isDirectory: true) + try FileManager.default.createDirectory( + at: nested, withIntermediateDirectories: true) + let mapping = PathMapping(mounts: [ + .init(virtual: "/outer", host: temp.path), + .init(virtual: "/inner", host: nested.path) + ]) + #expect(mapping.virtualPath(forHost: nested.path + "/f") + == "/inner/f") + #expect(mapping.virtualPath(forHost: temp.path + "/other") + == "/outer/other") + } + + // MARK: - Shell.resolve translation (Facade B, inbound) + + @Test func resolveTranslatesVirtualPathsUnderMapping() async throws { + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let mapping = Self.mapping(workspace: workspace, temp: temp) + var env = Environment() + env.workingDirectory = "/batch" + let shell = Shell(environment: env) + shell.sandbox = .confined(to: mapping, home: "/batch") + + // Absolute virtual paths translate through the table. + #expect(shell.resolve("/tmp/scratch.txt").path + == temp.path + "/scratch.txt") + #expect(shell.resolve("/batch/data.json").path + == workspace.path + "/data.json") + // Relative paths resolve against the VIRTUAL cwd, then + // translate. + #expect(shell.resolve("data.json").path + == workspace.path + "/data.json") + #expect(shell.resolve("../tmp/x").path == temp.path + "/x") + // Virtual paths outside every mount are VOIDED — never handed + // back as host-interpretable text. The sentinel keeps the + // virtual spelling for diagnostics, can't exist on disk, and + // the gate denies it. + #expect(shell.resolve("/etc/passwd").path + == Shell.unmappedPathSentinel + "/etc/passwd") + #expect(shell.displayPath(for: shell.resolve("/etc/passwd")) + == "/etc/passwd") + + // The static accessors agree under the binding. + try await shell.withCurrent { + #expect(Shell.resolve("/tmp/y").path == temp.path + "/y") + #expect(Shell.currentDirectory.path == workspace.path) + } + } + + @Test func hostSpellingsDoNotResolveUnderMapping() async throws { + // The namespace boundary, not just the containment boundary: + // a script that GUESSES (or was leaked) the host path of a + // mounted directory must not be able to address files through + // it. Pre-fix, resolve passed the unmapped host spelling + // through unchanged and the gate then authorized it — it IS + // under a canonical host root. Now it voids, the gate denies, + // and Facade B agrees with the bash-side mounted filesystem + // (which reports ENOENT for host spellings). + // + // Virtual prefixes here deliberately avoid `/tmp`: on Linux + // the platform temp root IS `/tmp`, so a host spelling under + // it would prefix-match a virtual `/tmp` mount and (by + // design) translate as an ordinary virtual path — into the + // mount's own backing, never the host location, so that + // collision is harmless; the voiding contract under test + // here is about spellings that match NO mount. + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + try Data("secret".utf8).write( + to: workspace.appendingPathComponent("secret.txt")) + let mapping = PathMapping(mounts: [ + .init(virtual: "/batch", host: workspace.path), + .init(virtual: "/scratch", host: temp.path) + ]) + var env = Environment() + env.workingDirectory = "/batch" + let shell = Shell(environment: env) + shell.sandbox = .confined(to: mapping, home: "/batch") + + for hostSpelling in [ + workspace.path + "/secret.txt", + workspace.resolvingSymlinksInPath().path + "/secret.txt", + temp.path + ] { + let resolved = shell.resolve(hostSpelling) + #expect(resolved.path.hasPrefix(Shell.unmappedPathSentinel), + "host spelling resolved to \(resolved.path)") + #expect(!FileManager.default.fileExists(atPath: resolved.path)) + await #expect(throws: Sandbox.Denial.self) { + try await shell.sandbox!.authorize(resolved) + } + } + // The voided path can't be `..`-escaped back out of the + // sentinel either. + let sneaky = shell.resolve("/nope/../../" + workspace.path) + #expect(sneaky.path.hasPrefix(Shell.unmappedPathSentinel)) + } + + @Test func resolveWithoutMappingIsUnchanged() { + var env = Environment() + env.workingDirectory = "/virtual/cwd" + let shell = Shell(environment: env) + // No sandbox at all. + #expect(shell.resolve("/tmp/x").path == "/tmp/x") + #expect(shell.resolve("f").path == "/virtual/cwd/f") + // A sandbox without a mapping (rooted) doesn't translate + // either. + shell.sandbox = .rooted( + at: FileManager.default.temporaryDirectory) + #expect(shell.resolve("/tmp/x").path == "/tmp/x") + } + + // MARK: - Shell.displayPath folding (Facade B, outbound) + + @Test func displayPathFoldsHostToVirtual() async throws { + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let mapping = Self.mapping(workspace: workspace, temp: temp) + let shell = Shell() + shell.sandbox = .confined(to: mapping, home: "/batch") + + #expect(shell.displayPath(for: temp.path + "/f") == "/tmp/f") + #expect(shell.displayPath( + for: URL(fileURLWithPath: workspace.path + "/a/b")) + == "/batch/a/b") + // Round trip: resolve → displayPath is the identity on the + // virtual spelling. + #expect(shell.displayPath(for: shell.resolve("/tmp/round")) + == "/tmp/round") + // Unmapped host paths display as-is. + #expect(shell.displayPath(for: "/usr/lib/x") == "/usr/lib/x") + // Without a mapping, identity. + let plain = Shell() + #expect(plain.displayPath(for: temp.path) == temp.path) + } +} diff --git a/Tests/ShellKitTests/SandboxConfinedTests.swift b/Tests/ShellKitTests/SandboxConfinedTests.swift new file mode 100644 index 0000000..731787f --- /dev/null +++ b/Tests/ShellKitTests/SandboxConfinedTests.swift @@ -0,0 +1,183 @@ +import Foundation +import Testing +@testable import ShellKit + +/// The `Sandbox.confined(to:)` gate over a ``PathMapping`` — +/// Facade B's boundary (#SwiftBash 83): canonical containment under +/// the mapping's host roots, symlink-escape rejection, region/temp +/// derivation, and the network policy knobs. Translation mechanics +/// live in `PathMappingTests`. +@Suite(.timeLimit(.minutes(1))) struct SandboxConfinedTests { + + /// On-disk workspace + per-instance temp dir, shaped like the + /// SwiftBash CLI's `--sandbox ` setup. Caller removes both. + private static func makeRoots() throws -> (workspace: URL, temp: URL) { + let base = FileManager.default.temporaryDirectory + let workspace = base.appendingPathComponent( + "shellkit-ws-\(UUID().uuidString)", isDirectory: true) + let temp = base.appendingPathComponent( + "shellkit-tmp-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: workspace, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: temp, withIntermediateDirectories: true) + return (workspace, temp) + } + + private static func mapping(workspace: URL, temp: URL) -> PathMapping { + PathMapping(mounts: [ + .init(virtual: "/batch", host: workspace.path), + .init(virtual: "/tmp", host: temp.path) + ]) + } + + @Test func confinedAuthorizesHostSpaceOnly() async throws { + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let mapping = Self.mapping(workspace: workspace, temp: temp) + let sandbox = Sandbox.confined(to: mapping, home: "/batch") + + // Host paths under either mount root pass — existing or not. + try await sandbox.authorize(workspace.appendingPathComponent("new/file")) + try await sandbox.authorize(temp) + try await sandbox.authorize(temp.appendingPathComponent("probe.txt")) + + // The VIRTUAL spelling is not host space: a literal `/tmp/...` + // (the host's shared temp dir!) or `/batch/...` is denied — + // callers reach the gate with `Shell.resolve` output. + await #expect(throws: Sandbox.Denial.self) { + try await sandbox.authorize(URL(fileURLWithPath: "/tmp/leak")) + } + await #expect(throws: Sandbox.Denial.self) { + try await sandbox.authorize(URL(fileURLWithPath: "/batch/f")) + } + // Outside both roots. + await #expect(throws: Sandbox.Denial.self) { + try await sandbox.authorize(URL(fileURLWithPath: "/etc/passwd")) + } + // Sibling/prefix collision: `extra` must not pass. + await #expect(throws: Sandbox.Denial.self) { + try await sandbox.authorize( + URL(fileURLWithPath: temp.path + "extra")) + } + // The shared platform temp root is NOT authorized — only the + // per-instance dir under it. + await #expect(throws: Sandbox.Denial.self) { + try await sandbox.authorize( + FileManager.default.temporaryDirectory) + } + } + +#if !os(Windows) + @Test func confinedRejectsSymlinkEscape() async throws { + // A link planted inside a mount pointing outside it resolves + // out of every canonical host root → denied (the FileManager- + // backed caller would otherwise follow it out of the sandbox). + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let link = temp.appendingPathComponent("escape-link").path + try FileManager.default.createSymbolicLink( + atPath: link, withDestinationPath: "/") + let sandbox = Sandbox.confined( + to: Self.mapping(workspace: workspace, temp: temp), + home: "/batch") + await #expect(throws: Sandbox.Denial.self) { + try await sandbox.authorize(URL(fileURLWithPath: link)) + } + } + + @Test func confinedAcceptsSymlinkSpelledMountRoot() async throws { + // A mount whose host root is itself reached through a symlink + // still authorizes: both sides canonicalise to the same + // prefix. + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let link = FileManager.default.temporaryDirectory + .appendingPathComponent("shellkit-link-\(UUID().uuidString)") + .path + try FileManager.default.createSymbolicLink( + atPath: link, withDestinationPath: temp.path) + defer { try? FileManager.default.removeItem(atPath: link) } + + let mapping = PathMapping(mounts: [ + .init(virtual: "/batch", host: workspace.path), + .init(virtual: "/tmp", host: link) + ]) + let sandbox = Sandbox.confined(to: mapping, home: "/batch") + try await sandbox.authorize( + URL(fileURLWithPath: link).appendingPathComponent("f")) + try await sandbox.authorize(temp.appendingPathComponent("f")) + } +#endif + + @Test func confinedRegionsAndTempDerivation() throws { + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let mapping = Self.mapping(workspace: workspace, temp: temp) + + // temporaryDirectory defaults to the `/tmp` mount's host root. + let sandbox = Sandbox.confined(to: mapping, home: "/batch") + #expect(sandbox.temporaryDirectory.standardizedFileURL.path + == temp.standardizedFileURL.path) + // The home mount's host root anchors home/user regions — + // the workspace IS the user's home. + #expect(sandbox.homeDirectory.standardizedFileURL.path + == workspace.standardizedFileURL.path) + #expect(sandbox.userDirectory.standardizedFileURL.path + == workspace.standardizedFileURL.path) + // The mapping rides on the sandbox for the resolve facade. + #expect(sandbox.pathMapping?.mounts.count == 2) + + // Explicit override wins. + let custom = FileManager.default.temporaryDirectory + .appendingPathComponent("custom-\(UUID().uuidString)") + let overridden = Sandbox.confined( + to: mapping, home: "/batch", temporaryDirectory: custom) + #expect(overridden.temporaryDirectory == custom) + } + + @Test func confinedNetworkPolicy() async throws { + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let mapping = Self.mapping(workspace: workspace, temp: temp) + + // Default: non-file URLs denied. + let offline = Sandbox.confined(to: mapping, home: "/batch") + await #expect(throws: Sandbox.Denial.self) { + try await offline.authorize(URL(string: "https://example.com/")!) + } + + // allowedHosts admits matching hosts. + let allowing = Sandbox.confined( + to: mapping, home: "/batch", + allowedHosts: ["api.example.com"]) + try await allowing.authorize( + URL(string: "https://api.example.com/v1")!) + + // authorizeNetwork override routes non-file URLs; file URLs + // keep using the mapping gate. + struct Blocked: Error {} + let custom = Sandbox.confined( + to: mapping, home: "/batch", + authorizeNetwork: { _ in throw Blocked() }) + await #expect(throws: Blocked.self) { + try await custom.authorize(URL(string: "https://x.example/")!) + } + try await custom.authorize(temp.appendingPathComponent("ok")) + } +}