From 3c5c5492ce82984380a45b6ca47e84fab1fa8c13 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 15 Mar 2026 18:31:28 -0700 Subject: [PATCH 01/14] Added scoped run options --- README.md | 30 ++++++++++ Sources/Bash/BashSession.swift | 54 ++++++++++++++++++ Sources/Bash/Core/PathUtils.swift | 6 ++ Sources/Bash/FS/InMemoryFilesystem.swift | 26 ++++++++- Sources/Bash/FS/ReadWriteFilesystem.swift | 20 +++++++ Sources/Bash/Support/Types.swift | 22 ++++++++ .../BashTests/ParserAndFilesystemTests.swift | 24 ++++++++ Tests/BashTests/SessionIntegrationTests.swift | 55 +++++++++++++++++++ 8 files changed, 236 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index da63d51..5f88d6f 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,19 @@ let piped = await session.run("echo hello | tee out.txt > copy.txt") print(piped.exitCode) // 0 ``` +For isolated one-off overrides without mutating the session's persisted `cwd` or environment: + +```swift +let scoped = await session.run( + "pwd && echo $MODE", + options: RunOptions( + environment: ["MODE": "preview"], + currentDirectory: "/tmp" + ) +) +print(scoped.stdoutString) +``` + Optional `sqlite3` registration: ```swift @@ -181,6 +194,7 @@ public final actor BashSession { public init(rootDirectory: URL, options: SessionOptions = .init()) async throws public init(options: SessionOptions = .init()) async throws public func run(_ commandLine: String, stdin: Data = Data()) async -> CommandResult + public func run(_ commandLine: String, options: RunOptions) async -> CommandResult public func register(_ command: any BuiltinCommand.Type) async public var currentDirectory: String { get async } public var environment: [String: String] { get async } @@ -200,6 +214,19 @@ public struct CommandResult { } ``` +### `RunOptions` + +```swift +public struct RunOptions { + public var stdin: Data + public var environment: [String: String] + public var replaceEnvironment: Bool + public var currentDirectory: String? +} +``` + +Use `RunOptions` when you want a Cloudflare-style per-execution override without changing the session's persisted shell state. Filesystem mutations still persist; environment, working-directory, and function changes from that run do not. + ### `SessionOptions` ```swift @@ -245,6 +272,8 @@ Execution pipeline: 4. The session state is updated (`cwd`, environment, history). 5. `CommandResult` is returned. +`run(_:options:)` follows the same pipeline, but starts from temporary environment / cwd overrides and restores the session shell state afterward. + ### Supported Shell Features - Quoting and escaping (`'...'`, `"..."`, `\\`) @@ -280,6 +309,7 @@ Built-in filesystem options: Behavior guarantees: - All operations are scoped under the filesystem root. - For `ReadWriteFilesystem`, symlink escapes outside root are blocked. +- Filesystem implementations reject paths containing null bytes. - Built-in command stubs are created under `/bin` and `/usr/bin` inside the selected filesystem. - Unsupported platform features are surfaced as runtime `ShellError.unsupported(...)`, while all current package targets still compile. diff --git a/Sources/Bash/BashSession.swift b/Sources/Bash/BashSession.swift index b5ba7be..6504396 100644 --- a/Sources/Bash/BashSession.swift +++ b/Sources/Bash/BashSession.swift @@ -36,6 +36,60 @@ public final actor BashSession { } public func run(_ commandLine: String, stdin: Data = Data()) async -> CommandResult { + await run(commandLine, options: RunOptions(stdin: stdin)) + } + + public func run(_ commandLine: String, options: RunOptions) async -> CommandResult { + let usesTemporaryState = options.currentDirectory != nil + || !options.environment.isEmpty + || options.replaceEnvironment + guard usesTemporaryState else { + return await runPersistingState(commandLine, stdin: options.stdin) + } + + let savedCurrentDirectory = currentDirectoryStore + let savedEnvironment = environmentStore + let savedFunctions = shellFunctionStore + + if let overrideDirectory = options.currentDirectory { + do { + try PathUtils.validate(overrideDirectory) + } catch { + return CommandResult( + stdout: Data(), + stderr: Data("\(error)\n".utf8), + exitCode: 2 + ) + } + } + + if options.replaceEnvironment { + environmentStore = [:] + } + + if let overrideDirectory = options.currentDirectory { + currentDirectoryStore = PathUtils.normalize( + path: overrideDirectory, + currentDirectory: savedCurrentDirectory + ) + if options.environment["PWD"] == nil { + environmentStore["PWD"] = currentDirectoryStore + } + } + + if !options.environment.isEmpty { + environmentStore.merge(options.environment) { _, rhs in rhs } + } + + let result = await runPersistingState(commandLine, stdin: options.stdin) + + currentDirectoryStore = savedCurrentDirectory + environmentStore = savedEnvironment + shellFunctionStore = savedFunctions + return result + } + + private func runPersistingState(_ commandLine: String, stdin: Data) async -> CommandResult { let trimmed = commandLine.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return CommandResult(stdout: Data(), stderr: Data(), exitCode: 0) diff --git a/Sources/Bash/Core/PathUtils.swift b/Sources/Bash/Core/PathUtils.swift index 751f2a7..7465828 100644 --- a/Sources/Bash/Core/PathUtils.swift +++ b/Sources/Bash/Core/PathUtils.swift @@ -1,6 +1,12 @@ import Foundation enum PathUtils { + static func validate(_ path: String) throws { + if path.contains("\u{0}") { + throw ShellError.invalidPath(path) + } + } + static func normalize(path: String, currentDirectory: String) -> String { if path.isEmpty { return currentDirectory diff --git a/Sources/Bash/FS/InMemoryFilesystem.swift b/Sources/Bash/FS/InMemoryFilesystem.swift index a815271..42d338e 100644 --- a/Sources/Bash/FS/InMemoryFilesystem.swift +++ b/Sources/Bash/FS/InMemoryFilesystem.swift @@ -80,12 +80,14 @@ public final class InMemoryFilesystem: SessionConfigurableFilesystem, @unchecked } public func stat(path: String) async throws -> FileInfo { + try PathUtils.validate(path) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") let node = try node(at: normalized, followFinalSymlink: false) return fileInfo(for: node, path: normalized) } public func listDirectory(path: String) async throws -> [DirectoryEntry] { + try PathUtils.validate(path) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") let node = try node(at: normalized, followFinalSymlink: true) @@ -103,6 +105,7 @@ public final class InMemoryFilesystem: SessionConfigurableFilesystem, @unchecked } public func readFile(path: String) async throws -> Data { + try PathUtils.validate(path) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") let node = try node(at: normalized, followFinalSymlink: true) @@ -114,6 +117,7 @@ public final class InMemoryFilesystem: SessionConfigurableFilesystem, @unchecked } public func writeFile(path: String, data: Data, append: Bool) async throws { + try PathUtils.validate(path) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") guard normalized != "/" else { throw posixError(EISDIR) @@ -144,6 +148,7 @@ public final class InMemoryFilesystem: SessionConfigurableFilesystem, @unchecked } public func createDirectory(path: String, recursive: Bool) async throws { + try PathUtils.validate(path) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") if normalized == "/" { return @@ -181,6 +186,7 @@ public final class InMemoryFilesystem: SessionConfigurableFilesystem, @unchecked } public func remove(path: String, recursive: Bool) async throws { + try PathUtils.validate(path) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") if normalized == "/" { throw posixError(EPERM) @@ -201,6 +207,8 @@ public final class InMemoryFilesystem: SessionConfigurableFilesystem, @unchecked } public func move(from sourcePath: String, to destinationPath: String) async throws { + try PathUtils.validate(sourcePath) + try PathUtils.validate(destinationPath) let source = PathUtils.normalize(path: sourcePath, currentDirectory: "/") let destination = PathUtils.normalize(path: destinationPath, currentDirectory: "/") @@ -234,6 +242,8 @@ public final class InMemoryFilesystem: SessionConfigurableFilesystem, @unchecked } public func copy(from sourcePath: String, to destinationPath: String, recursive: Bool) async throws { + try PathUtils.validate(sourcePath) + try PathUtils.validate(destinationPath) let source = PathUtils.normalize(path: sourcePath, currentDirectory: "/") let destination = PathUtils.normalize(path: destinationPath, currentDirectory: "/") @@ -254,6 +264,8 @@ public final class InMemoryFilesystem: SessionConfigurableFilesystem, @unchecked } public func createSymlink(path: String, target: String) async throws { + try PathUtils.validate(path) + try PathUtils.validate(target) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") guard normalized != "/" else { throw posixError(EEXIST) @@ -271,6 +283,8 @@ public final class InMemoryFilesystem: SessionConfigurableFilesystem, @unchecked } public func createHardLink(path: String, target: String) async throws { + try PathUtils.validate(path) + try PathUtils.validate(target) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") guard normalized != "/" else { throw posixError(EEXIST) @@ -294,6 +308,7 @@ public final class InMemoryFilesystem: SessionConfigurableFilesystem, @unchecked } public func readSymlink(path: String) async throws -> String { + try PathUtils.validate(path) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") let node = try node(at: normalized, followFinalSymlink: false) @@ -305,6 +320,7 @@ public final class InMemoryFilesystem: SessionConfigurableFilesystem, @unchecked } public func setPermissions(path: String, permissions: Int) async throws { + try PathUtils.validate(path) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") let node = try node(at: normalized, followFinalSymlink: false) node.permissions = permissions @@ -312,11 +328,17 @@ public final class InMemoryFilesystem: SessionConfigurableFilesystem, @unchecked } public func resolveRealPath(path: String) async throws -> String { - try resolvePath(path: PathUtils.normalize(path: path, currentDirectory: "/"), followFinalSymlink: true, symlinkDepth: 0) + try PathUtils.validate(path) + return try resolvePath( + path: PathUtils.normalize(path: path, currentDirectory: "/"), + followFinalSymlink: true, + symlinkDepth: 0 + ) } public func exists(path: String) async -> Bool { do { + try PathUtils.validate(path) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") _ = try node(at: normalized, followFinalSymlink: true) return true @@ -326,6 +348,8 @@ public final class InMemoryFilesystem: SessionConfigurableFilesystem, @unchecked } public func glob(pattern: String, currentDirectory: String) async throws -> [String] { + try PathUtils.validate(pattern) + try PathUtils.validate(currentDirectory) let normalizedPattern = PathUtils.normalize(path: pattern, currentDirectory: currentDirectory) if !PathUtils.containsGlob(normalizedPattern) { return await exists(path: normalizedPattern) ? [normalizedPattern] : [] diff --git a/Sources/Bash/FS/ReadWriteFilesystem.swift b/Sources/Bash/FS/ReadWriteFilesystem.swift index 1ae61b7..8e5b2f9 100644 --- a/Sources/Bash/FS/ReadWriteFilesystem.swift +++ b/Sources/Bash/FS/ReadWriteFilesystem.swift @@ -23,6 +23,7 @@ public final class ReadWriteFilesystem: ShellFilesystem, @unchecked Sendable { } public func stat(path: String) async throws -> FileInfo { + try PathUtils.validate(path) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") let url = try existingURL(for: normalized) let attributes = try fileManager.attributesOfItem(atPath: url.path) @@ -45,6 +46,7 @@ public final class ReadWriteFilesystem: ShellFilesystem, @unchecked Sendable { } public func listDirectory(path: String) async throws -> [DirectoryEntry] { + try PathUtils.validate(path) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") let url = try existingURL(for: normalized) var isDirectory: ObjCBool = false @@ -64,12 +66,14 @@ public final class ReadWriteFilesystem: ShellFilesystem, @unchecked Sendable { } public func readFile(path: String) async throws -> Data { + try PathUtils.validate(path) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") let url = try existingURL(for: normalized) return try Data(contentsOf: url) } public func writeFile(path: String, data: Data, append: Bool) async throws { + try PathUtils.validate(path) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") let url = try creationURL(for: normalized) @@ -87,12 +91,14 @@ public final class ReadWriteFilesystem: ShellFilesystem, @unchecked Sendable { } public func createDirectory(path: String, recursive: Bool) async throws { + try PathUtils.validate(path) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") let url = try creationURL(for: normalized) try fileManager.createDirectory(at: url, withIntermediateDirectories: recursive) } public func remove(path: String, recursive: Bool) async throws { + try PathUtils.validate(path) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") let url = try existingURL(for: normalized) @@ -111,6 +117,8 @@ public final class ReadWriteFilesystem: ShellFilesystem, @unchecked Sendable { } public func move(from sourcePath: String, to destinationPath: String) async throws { + try PathUtils.validate(sourcePath) + try PathUtils.validate(destinationPath) let source = try existingURL(for: PathUtils.normalize(path: sourcePath, currentDirectory: "/")) let destination = try creationURL(for: PathUtils.normalize(path: destinationPath, currentDirectory: "/")) let parent = destination.deletingLastPathComponent() @@ -119,6 +127,8 @@ public final class ReadWriteFilesystem: ShellFilesystem, @unchecked Sendable { } public func copy(from sourcePath: String, to destinationPath: String, recursive: Bool) async throws { + try PathUtils.validate(sourcePath) + try PathUtils.validate(destinationPath) let sourceVirtual = PathUtils.normalize(path: sourcePath, currentDirectory: "/") let source = try existingURL(for: sourceVirtual) let destination = try creationURL(for: PathUtils.normalize(path: destinationPath, currentDirectory: "/")) @@ -142,6 +152,8 @@ public final class ReadWriteFilesystem: ShellFilesystem, @unchecked Sendable { } public func createSymlink(path: String, target: String) async throws { + try PathUtils.validate(path) + try PathUtils.validate(target) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") let url = try creationURL(for: normalized) let parent = url.deletingLastPathComponent() @@ -150,6 +162,8 @@ public final class ReadWriteFilesystem: ShellFilesystem, @unchecked Sendable { } public func createHardLink(path: String, target: String) async throws { + try PathUtils.validate(path) + try PathUtils.validate(target) let normalizedLink = PathUtils.normalize(path: path, currentDirectory: "/") let normalizedTarget = PathUtils.normalize(path: target, currentDirectory: "/") let linkURL = try creationURL(for: normalizedLink) @@ -161,18 +175,21 @@ public final class ReadWriteFilesystem: ShellFilesystem, @unchecked Sendable { } public func readSymlink(path: String) async throws -> String { + try PathUtils.validate(path) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") let url = try existingURL(for: normalized) return try fileManager.destinationOfSymbolicLink(atPath: url.path) } public func setPermissions(path: String, permissions: Int) async throws { + try PathUtils.validate(path) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") let url = try existingURL(for: normalized) try fileManager.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path) } public func resolveRealPath(path: String) async throws -> String { + try PathUtils.validate(path) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") let url = try existingURL(for: normalized) let resolved = url.resolvingSymlinksInPath().standardizedFileURL @@ -182,6 +199,7 @@ public final class ReadWriteFilesystem: ShellFilesystem, @unchecked Sendable { public func exists(path: String) async -> Bool { do { + try PathUtils.validate(path) let normalized = PathUtils.normalize(path: path, currentDirectory: "/") let url = try existingOrPotentialURL(for: normalized) return fileManager.fileExists(atPath: url.path) @@ -191,6 +209,8 @@ public final class ReadWriteFilesystem: ShellFilesystem, @unchecked Sendable { } public func glob(pattern: String, currentDirectory: String) async throws -> [String] { + try PathUtils.validate(pattern) + try PathUtils.validate(currentDirectory) let normalizedPattern = PathUtils.normalize(path: pattern, currentDirectory: currentDirectory) if !PathUtils.containsGlob(normalizedPattern) { return await exists(path: normalizedPattern) ? [normalizedPattern] : [] diff --git a/Sources/Bash/Support/Types.swift b/Sources/Bash/Support/Types.swift index fe3d1f7..0717ac1 100644 --- a/Sources/Bash/Support/Types.swift +++ b/Sources/Bash/Support/Types.swift @@ -20,6 +20,25 @@ public struct CommandResult: Sendable { } } +public struct RunOptions: Sendable { + public var stdin: Data + public var environment: [String: String] + public var replaceEnvironment: Bool + public var currentDirectory: String? + + public init( + stdin: Data = Data(), + environment: [String: String] = [:], + replaceEnvironment: Bool = false, + currentDirectory: String? = nil + ) { + self.stdin = stdin + self.environment = environment + self.replaceEnvironment = replaceEnvironment + self.currentDirectory = currentDirectory + } +} + public enum SessionLayout: Sendable { case unixLike case rootOnly @@ -128,6 +147,9 @@ public enum ShellError: Error, CustomStringConvertible, Sendable { public var description: String { switch self { case let .invalidPath(path): + if path.contains("\u{0}") { + return "path contains null byte" + } return "invalid path: \(path)" case let .parserError(message): return message diff --git a/Tests/BashTests/ParserAndFilesystemTests.swift b/Tests/BashTests/ParserAndFilesystemTests.swift index 7b9f42d..432cdc2 100644 --- a/Tests/BashTests/ParserAndFilesystemTests.swift +++ b/Tests/BashTests/ParserAndFilesystemTests.swift @@ -182,6 +182,30 @@ struct ParserAndFilesystemTests { #expect(read.stderrString.contains("invalid path")) } + @Test("filesystems reject paths with null bytes") + func filesystemsRejectPathsWithNullBytes() async throws { + let inMemory = InMemoryFilesystem() + try inMemory.configureForSession() + + do { + _ = try await inMemory.readFile(path: "/bad\u{0}name") + Issue.record("expected in-memory null-byte rejection") + } catch { + #expect("\(error)".contains("null byte")) + } + + let root = try TestSupport.makeTempDirectory(prefix: "BashNullPath") + defer { TestSupport.removeDirectory(root) } + + let readWrite = try ReadWriteFilesystem(rootDirectory: root) + do { + try await readWrite.writeFile(path: "/bad\u{0}name", data: Data(), append: false) + Issue.record("expected read-write null-byte rejection") + } catch { + #expect("\(error)".contains("null byte")) + } + } + @Test("command stubs created for path invocation") func commandStubsCreatedForPathInvocation() async throws { let (session, root) = try await TestSupport.makeSession() diff --git a/Tests/BashTests/SessionIntegrationTests.swift b/Tests/BashTests/SessionIntegrationTests.swift index c3c9933..238c73e 100644 --- a/Tests/BashTests/SessionIntegrationTests.swift +++ b/Tests/BashTests/SessionIntegrationTests.swift @@ -54,6 +54,61 @@ struct SessionIntegrationTests { #expect(fallback.stdoutString == "fallback\n") } + @Test("run options environment override is isolated from session state") + func runOptionsEnvironmentOverrideIsIsolatedFromSessionState() async throws { + let (session, root) = try await TestSupport.makeSession() + defer { TestSupport.removeDirectory(root) } + + let isolated = await session.run( + "export TEMP=mutated; echo $TEMP", + options: RunOptions(environment: ["TEMP": "seed"]) + ) + #expect(isolated.exitCode == 0) + #expect(isolated.stdoutString == "mutated\n") + + let restored = await session.run("echo $TEMP") + #expect(restored.exitCode == 0) + #expect(restored.stdoutString == "\n") + } + + @Test("run options current directory override is isolated from session state") + func runOptionsCurrentDirectoryOverrideIsIsolatedFromSessionState() async throws { + let (session, root) = try await TestSupport.makeSession() + defer { TestSupport.removeDirectory(root) } + + _ = await session.run("mkdir -p tempdir && echo scoped > tempdir/file.txt") + + let isolated = await session.run( + "pwd && cat file.txt", + options: RunOptions(currentDirectory: "/home/user/tempdir") + ) + #expect(isolated.exitCode == 0) + #expect(isolated.stdoutString == "/home/user/tempdir\nscoped\n") + + let restored = await session.run("pwd") + #expect(restored.exitCode == 0) + #expect(restored.stdoutString == "/home/user\n") + } + + @Test("run options can replace environment without mutating session") + func runOptionsCanReplaceEnvironmentWithoutMutatingSession() async throws { + let (session, root) = try await TestSupport.makeSession() + defer { TestSupport.removeDirectory(root) } + + _ = await session.run("export PERSIST=value") + + let isolated = await session.run( + "printenv PERSIST", + options: RunOptions(replaceEnvironment: true) + ) + #expect(isolated.exitCode == 1) + #expect(isolated.stdoutString.isEmpty) + + let restored = await session.run("printenv PERSIST") + #expect(restored.exitCode == 0) + #expect(restored.stdoutString == "value\n") + } + @Test("command substitution writes evaluated output") func commandSubstitutionWritesEvaluatedOutput() async throws { let (session, root) = try await TestSupport.makeSession() From bd34c8ea5506d5d73499a16bc7df08cd58cd2b4f Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 15 Mar 2026 19:34:34 -0700 Subject: [PATCH 02/14] Added permission handling --- README.md | 45 +++++++++ Sources/Bash/BashSession.swift | 3 + Sources/Bash/Commands/BuiltinCommand.swift | 17 +++- Sources/Bash/Commands/NetworkCommands.swift | 41 ++++++++ Sources/Bash/Core/ShellExecutor.swift | 22 ++++- Sources/Bash/Support/Permissions.swift | 58 +++++++++++ Sources/Bash/Support/Types.swift | 3 + Tests/BashTests/SessionIntegrationTests.swift | 98 +++++++++++++++++++ Tests/BashTests/TestSupport.swift | 6 +- docs/command-parity-gaps.md | 2 +- 10 files changed, 288 insertions(+), 7 deletions(-) create mode 100644 Sources/Bash/Support/Permissions.swift diff --git a/README.md b/README.md index 5f88d6f..7d9ca40 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,30 @@ public struct RunOptions { Use `RunOptions` when you want a Cloudflare-style per-execution override without changing the session's persisted shell state. Filesystem mutations still persist; environment, working-directory, and function changes from that run do not. +### `PermissionRequest` and `PermissionDecision` + +```swift +public struct PermissionRequest { + public enum Kind { + case network(NetworkPermissionRequest) + } + + public var command: String + public var kind: Kind +} + +public struct NetworkPermissionRequest { + public var url: String + public var method: String +} + +public enum PermissionDecision { + case allow + case allowForSession + case deny(message: String?) +} +``` + ### `SessionOptions` ```swift @@ -236,6 +260,7 @@ public struct SessionOptions { public var initialEnvironment: [String: String] public var enableGlobbing: Bool public var maxHistory: Int + public var permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? public var secretPolicy: SecretHandlingPolicy public var secretResolver: (any SecretReferenceResolving)? public var secretOutputRedactor: any SecretOutputRedacting @@ -248,10 +273,29 @@ Defaults: - `initialEnvironment`: `[:]` - `enableGlobbing`: `true` - `maxHistory`: `1000` +- `permissionHandler`: `nil` - `secretPolicy`: `.off` - `secretResolver`: `nil` - `secretOutputRedactor`: `DefaultSecretOutputRedactor()` +Use `permissionHandler` when the host app or agent needs explicit control over outbound permissions. Returning `.allow` grants the current request once, `.allowForSession` caches an exact-match request for the life of that `BashSession`, and `.deny(message:)` blocks it with a user-visible error. If you want broader or persistent memory across sessions, keep that policy in the host and decide what to return from the callback. + +Example HTTP(S) permission gate: + +```swift +let options = SessionOptions( + permissionHandler: { request in + switch request.kind { + case let .network(network): + if network.url.hasPrefix("https://api.example.com/") { + return .allowForSession + } + return .deny(message: "network access denied") + } + } +) +``` + Available filesystem implementations: - `ReadWriteFilesystem`: root-jail wrapper over real disk I/O. - `InMemoryFilesystem`: fully in-memory filesystem with no disk writes. @@ -471,6 +515,7 @@ All implemented commands support `--help`. | `html-to-markdown` | `-b/--bullet `, `-c/--code `, `-r/--hr `, `--heading-style `; input from file or stdin; strips `script/style/footer` blocks; supports nested lists and Markdown table rendering | When `SessionOptions.secretPolicy` is `.resolveAndRedact` or `.strict`, `curl` resolves `secretref:v1:...` tokens in headers/body arguments and output redaction replaces resolved values with their reference tokens. +When `SessionOptions.permissionHandler` is set, `curl` and `wget` ask it before outbound HTTP(S) requests. `data:` and jailed `file:` URLs do not trigger the callback. ## Command Behaviors and Notes diff --git a/Sources/Bash/BashSession.swift b/Sources/Bash/BashSession.swift index 6504396..dd9856d 100644 --- a/Sources/Bash/BashSession.swift +++ b/Sources/Bash/BashSession.swift @@ -4,6 +4,7 @@ public final actor BashSession { private let filesystemStore: any ShellFilesystem private let options: SessionOptions private let jobManager: ShellJobManager + private let permissionAuthorizer: PermissionAuthorizer private var currentDirectoryStore: String private var environmentStore: [String: String] @@ -236,6 +237,7 @@ public final actor BashSession { self.options = options filesystemStore = configuredFilesystem jobManager = ShellJobManager() + permissionAuthorizer = PermissionAuthorizer(handler: options.permissionHandler) commandRegistry = [:] shellFunctionStore = [:] @@ -403,6 +405,7 @@ public final actor BashSession { shellFunctions: shellFunctions, enableGlobbing: options.enableGlobbing, jobControl: jobControl, + permissionAuthorizer: permissionAuthorizer, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, diff --git a/Sources/Bash/Commands/BuiltinCommand.swift b/Sources/Bash/Commands/BuiltinCommand.swift index 0743a55..b5f5afb 100644 --- a/Sources/Bash/Commands/BuiltinCommand.swift +++ b/Sources/Bash/Commands/BuiltinCommand.swift @@ -19,6 +19,7 @@ public struct CommandContext: Sendable { public var stderr: Data let secretTracker: SecretExposureTracker? let jobControl: (any ShellJobControlling)? + let permissionAuthorizer: PermissionAuthorizer public init( commandName: String, @@ -52,7 +53,8 @@ public struct CommandContext: Sendable { stdout: stdout, stderr: stderr, secretTracker: nil, - jobControl: nil + jobControl: nil, + permissionAuthorizer: PermissionAuthorizer() ) } @@ -72,7 +74,8 @@ public struct CommandContext: Sendable { stdout: Data = Data(), stderr: Data = Data(), secretTracker: SecretExposureTracker?, - jobControl: (any ShellJobControlling)? = nil + jobControl: (any ShellJobControlling)? = nil, + permissionAuthorizer: PermissionAuthorizer = PermissionAuthorizer() ) { self.commandName = commandName self.arguments = arguments @@ -90,6 +93,7 @@ public struct CommandContext: Sendable { self.stderr = stderr self.secretTracker = secretTracker self.jobControl = jobControl + self.permissionAuthorizer = permissionAuthorizer } public mutating func writeStdout(_ string: String) { @@ -168,6 +172,12 @@ public struct CommandContext: Sendable { return value } + public func requestPermission( + _ request: PermissionRequest + ) async -> PermissionDecision { + await permissionAuthorizer.authorize(request) + } + public mutating func runSubcommand( _ argv: [String], stdin: Data? = nil @@ -210,7 +220,8 @@ public struct CommandContext: Sendable { environment: environment, stdin: stdin ?? self.stdin, secretTracker: secretTracker, - jobControl: jobControl + jobControl: jobControl, + permissionAuthorizer: permissionAuthorizer ) let exitCode = await implementation.runCommand(&childContext, commandArgs) diff --git a/Sources/Bash/Commands/NetworkCommands.swift b/Sources/Bash/Commands/NetworkCommands.swift index e8c1a4b..b46850a 100644 --- a/Sources/Bash/Commands/NetworkCommands.swift +++ b/Sources/Bash/Commands/NetworkCommands.swift @@ -162,6 +162,16 @@ struct CurlCommand: BuiltinCommand { } let method = resolvedMethod(options: options) + if scheme == "http" || scheme == "https", + let denied = await authorizeNetworkRequest( + url: url, + method: method, + context: &context, + options: options + ) { + return denied + } + let requestBodyResult = await buildRequestBody( options: options, dataTokens: options.data, @@ -1181,6 +1191,37 @@ struct CurlCommand: BuiltinCommand { return HTTPCookie(properties: properties) } + @discardableResult + private static func authorizeNetworkRequest( + url: URL, + method: String, + context: inout CommandContext, + options: Options + ) async -> Int32? { + let request = PermissionRequest( + command: context.commandName, + kind: .network( + NetworkPermissionRequest( + url: url.absoluteString, + method: method + ) + ) + ) + + let decision = await context.requestPermission(request) + if case let .deny(message) = decision { + let reason = message ?? "network access denied: \(method) \(url.absoluteString)" + return emitError( + &context, + options: options, + code: 1, + message: "curl: \(reason)\n" + ) + } + + return nil + } + @discardableResult private static func emitError( _ context: inout CommandContext, diff --git a/Sources/Bash/Core/ShellExecutor.swift b/Sources/Bash/Core/ShellExecutor.swift index 4198f42..1bcfb91 100644 --- a/Sources/Bash/Core/ShellExecutor.swift +++ b/Sources/Bash/Core/ShellExecutor.swift @@ -24,6 +24,7 @@ enum ShellExecutor { shellFunctions: [String: String], enableGlobbing: Bool, jobControl: (any ShellJobControlling)?, + permissionAuthorizer: PermissionAuthorizer, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -79,6 +80,7 @@ enum ShellExecutor { shellFunctions: shellFunctions, enableGlobbing: backgroundEnableGlobbing, jobControl: nil, + permissionAuthorizer: permissionAuthorizer, secretPolicy: backgroundSecretPolicy, secretResolver: backgroundSecretResolver, secretTracker: localTracker, @@ -114,6 +116,7 @@ enum ShellExecutor { shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, jobControl: jobControl, + permissionAuthorizer: permissionAuthorizer, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -158,6 +161,7 @@ enum ShellExecutor { shellFunctions: [String: String], enableGlobbing: Bool, jobControl: (any ShellJobControlling)?, + permissionAuthorizer: PermissionAuthorizer, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -179,6 +183,7 @@ enum ShellExecutor { shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, jobControl: jobControl, + permissionAuthorizer: permissionAuthorizer, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -206,6 +211,7 @@ enum ShellExecutor { shellFunctions: [String: String], enableGlobbing: Bool, jobControl: (any ShellJobControlling)?, + permissionAuthorizer: PermissionAuthorizer, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -225,6 +231,7 @@ enum ShellExecutor { commandRegistry: commandRegistry, shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, + permissionAuthorizer: permissionAuthorizer, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -291,7 +298,8 @@ enum ShellExecutor { environment: environment, stdin: input, secretTracker: secretTracker, - jobControl: jobControl + jobControl: jobControl, + permissionAuthorizer: permissionAuthorizer ) let exitCode = await implementation.runCommand(&context, commandArgs) @@ -315,6 +323,7 @@ enum ShellExecutor { shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, jobControl: jobControl, + permissionAuthorizer: permissionAuthorizer, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -440,6 +449,7 @@ enum ShellExecutor { shellFunctions: [String: String], enableGlobbing: Bool, jobControl: (any ShellJobControlling)?, + permissionAuthorizer: PermissionAuthorizer, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -477,6 +487,7 @@ enum ShellExecutor { shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, jobControl: jobControl, + permissionAuthorizer: permissionAuthorizer, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -911,6 +922,7 @@ enum ShellExecutor { commandRegistry: [String: AnyBuiltinCommand], shellFunctions: [String: String], enableGlobbing: Bool, + permissionAuthorizer: PermissionAuthorizer, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -933,6 +945,7 @@ enum ShellExecutor { commandRegistry: commandRegistry, shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, + permissionAuthorizer: permissionAuthorizer, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -949,6 +962,7 @@ enum ShellExecutor { commandRegistry: [String: AnyBuiltinCommand], shellFunctions: [String: String], enableGlobbing: Bool, + permissionAuthorizer: PermissionAuthorizer, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -1032,6 +1046,7 @@ enum ShellExecutor { commandRegistry: commandRegistry, shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, + permissionAuthorizer: permissionAuthorizer, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -1128,6 +1143,7 @@ enum ShellExecutor { commandRegistry: [String: AnyBuiltinCommand], shellFunctions: [String: String], enableGlobbing: Bool, + permissionAuthorizer: PermissionAuthorizer, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -1233,6 +1249,7 @@ enum ShellExecutor { commandRegistry: commandRegistry, shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, + permissionAuthorizer: permissionAuthorizer, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -1285,6 +1302,7 @@ enum ShellExecutor { commandRegistry: [String: AnyBuiltinCommand], shellFunctions: [String: String], enableGlobbing: Bool, + permissionAuthorizer: PermissionAuthorizer, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -1299,6 +1317,7 @@ enum ShellExecutor { commandRegistry: commandRegistry, shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, + permissionAuthorizer: permissionAuthorizer, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -1336,6 +1355,7 @@ enum ShellExecutor { shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, jobControl: nil, + permissionAuthorizer: permissionAuthorizer, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, diff --git a/Sources/Bash/Support/Permissions.swift b/Sources/Bash/Support/Permissions.swift new file mode 100644 index 0000000..455f175 --- /dev/null +++ b/Sources/Bash/Support/Permissions.swift @@ -0,0 +1,58 @@ +public struct PermissionRequest: Sendable, Hashable { + public enum Kind: Sendable, Hashable { + case network(NetworkPermissionRequest) + } + + public var command: String + public var kind: Kind + + public init(command: String, kind: Kind) { + self.command = command + self.kind = kind + } +} + +public struct NetworkPermissionRequest: Sendable, Hashable { + public var url: String + public var method: String + + public init(url: String, method: String) { + self.url = url + self.method = method + } +} + +public enum PermissionDecision: Sendable { + case allow + case allowForSession + case deny(message: String?) +} + +actor PermissionAuthorizer { + typealias Handler = @Sendable (PermissionRequest) async -> PermissionDecision + + private let handler: Handler? + private var sessionAllows: Set = [] + + init(handler: Handler? = nil) { + self.handler = handler + } + + func authorize(_ request: PermissionRequest) async -> PermissionDecision { + if sessionAllows.contains(request) { + return .allow + } + + guard let handler else { + return .allow + } + + let decision = await handler(request) + if case .allowForSession = decision { + sessionAllows.insert(request) + return .allow + } + + return decision + } +} diff --git a/Sources/Bash/Support/Types.swift b/Sources/Bash/Support/Types.swift index 0717ac1..c7a3a10 100644 --- a/Sources/Bash/Support/Types.swift +++ b/Sources/Bash/Support/Types.swift @@ -114,6 +114,7 @@ public struct SessionOptions: Sendable { public var initialEnvironment: [String: String] public var enableGlobbing: Bool public var maxHistory: Int + public var permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? public var secretPolicy: SecretHandlingPolicy public var secretResolver: (any SecretReferenceResolving)? public var secretOutputRedactor: any SecretOutputRedacting @@ -124,6 +125,7 @@ public struct SessionOptions: Sendable { initialEnvironment: [String: String] = [:], enableGlobbing: Bool = true, maxHistory: Int = 1_000, + permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? = nil, secretPolicy: SecretHandlingPolicy = .off, secretResolver: (any SecretReferenceResolving)? = nil, secretOutputRedactor: any SecretOutputRedacting = DefaultSecretOutputRedactor() @@ -133,6 +135,7 @@ public struct SessionOptions: Sendable { self.initialEnvironment = initialEnvironment self.enableGlobbing = enableGlobbing self.maxHistory = maxHistory + self.permissionHandler = permissionHandler self.secretPolicy = secretPolicy self.secretResolver = secretResolver self.secretOutputRedactor = secretOutputRedactor diff --git a/Tests/BashTests/SessionIntegrationTests.swift b/Tests/BashTests/SessionIntegrationTests.swift index 238c73e..570c334 100644 --- a/Tests/BashTests/SessionIntegrationTests.swift +++ b/Tests/BashTests/SessionIntegrationTests.swift @@ -2,6 +2,18 @@ import Foundation import Testing @testable import Bash +actor PermissionProbe { + private var requests: [PermissionRequest] = [] + + func record(_ request: PermissionRequest) { + requests.append(request) + } + + func snapshot() -> [PermissionRequest] { + requests + } +} + @Suite("Session Integration") struct SessionIntegrationTests { @Test("touch then ls mutates read-write filesystem") @@ -1534,6 +1546,92 @@ struct SessionIntegrationTests { #expect(unsupported.stderrString.contains("unsupported URL scheme")) } + @Test("curl permission handler can deny outbound http requests") + func curlPermissionHandlerCanDenyOutboundHTTPRequests() async throws { + let probe = PermissionProbe() + let (session, root) = try await TestSupport.makeSession( + permissionHandler: { request in + await probe.record(request) + return .deny(message: "network access denied") + } + ) + defer { TestSupport.removeDirectory(root) } + + let result = await session.run("curl http://127.0.0.1:1") + #expect(result.exitCode == 1) + #expect(result.stderrString == "curl: network access denied\n") + + let requests = await probe.snapshot() + #expect(requests.count == 1) + #expect(requests[0].command == "curl") + switch requests[0].kind { + case let .network(network): + #expect(network.url == "http://127.0.0.1:1") + #expect(network.method == "GET") + } + } + + @Test("curl permission handler allow once does not persist") + func curlPermissionHandlerAllowOnceDoesNotPersist() async throws { + let probe = PermissionProbe() + let (session, root) = try await TestSupport.makeSession( + permissionHandler: { request in + await probe.record(request) + return .allow + } + ) + defer { TestSupport.removeDirectory(root) } + + let first = await session.run("curl --connect-timeout 0.1 http://127.0.0.1:1") + let second = await session.run("curl --connect-timeout 0.1 http://127.0.0.1:1") + + #expect(first.exitCode != 0) + #expect(second.exitCode != 0) + + let requests = await probe.snapshot() + #expect(requests.count == 2) + } + + @Test("curl permission handler can allow for session") + func curlPermissionHandlerCanAllowForSession() async throws { + let probe = PermissionProbe() + let (session, root) = try await TestSupport.makeSession( + permissionHandler: { request in + await probe.record(request) + return .allowForSession + } + ) + defer { TestSupport.removeDirectory(root) } + + let first = await session.run("curl --connect-timeout 0.1 http://127.0.0.1:1") + let second = await session.run("curl --connect-timeout 0.1 http://127.0.0.1:1") + + #expect(first.exitCode != 0) + #expect(second.exitCode != 0) + + let requests = await probe.snapshot() + #expect(requests.count == 1) + } + + @Test("curl permission handler is skipped for non-http urls") + func curlPermissionHandlerIsSkippedForNonHTTPURLs() async throws { + let probe = PermissionProbe() + let (session, root) = try await TestSupport.makeSession( + permissionHandler: { request in + await probe.record(request) + return .deny(message: "network access denied") + } + ) + defer { TestSupport.removeDirectory(root) } + + let result = await session.run("curl data:text/plain,ok") + #expect(result.exitCode == 0) + #expect(result.stdoutString == "ok") + + let requests = await probe.snapshot() + #expect(requests.isEmpty) + } + @Test("html-to-markdown command parity chunk") func htmlToMarkdownCommandParityChunk() async throws { let (session, root) = try await TestSupport.makeSession() diff --git a/Tests/BashTests/TestSupport.swift b/Tests/BashTests/TestSupport.swift index e2b7e45..19125ff 100644 --- a/Tests/BashTests/TestSupport.swift +++ b/Tests/BashTests/TestSupport.swift @@ -12,7 +12,8 @@ enum TestSupport { static func makeSession( filesystem: (any ShellFilesystem)? = nil, layout: SessionLayout = .unixLike, - enableGlobbing: Bool = true + enableGlobbing: Bool = true, + permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? = nil ) async throws -> (session: BashSession, root: URL) { let root = try makeTempDirectory() let options = SessionOptions( @@ -20,7 +21,8 @@ enum TestSupport { layout: layout, initialEnvironment: [:], enableGlobbing: enableGlobbing, - maxHistory: 1_000 + maxHistory: 1_000, + permissionHandler: permissionHandler ) let session = try await BashSession(rootDirectory: root, options: options) diff --git a/docs/command-parity-gaps.md b/docs/command-parity-gaps.md index 8900c0a..58183f7 100644 --- a/docs/command-parity-gaps.md +++ b/docs/command-parity-gaps.md @@ -7,5 +7,5 @@ This document tracks major command parity gaps relative to `just-bash` and shell | Job control (`&`, `$!`, `jobs`, `fg`, `wait`, `ps`, `kill`) | Background execution, pseudo-PID tracking, process listing, and signal-style termination are supported for in-process commands with buffered stdout/stderr handoff. | Medium | No stopped-job state transitions (`bg`, `disown`, `SIGTSTP`/`SIGCONT`) and no true host-process/TTY semantics. | `Tests/BashTests/ParserAndFilesystemTests.swift`, `Tests/BashTests/SessionIntegrationTests.swift` | | Shell language (`$(...)`, `for`, functions) | Command substitution, here-documents via `<<` / `<<-`, unquoted heredoc expansion for the shell’s supported `$VAR` / `${...}` / `$((...))` / `$(...)` features, `if/elif/else`, `while`, `until`, `case`, `for ... in ...`, C-style `for ((...))`, function keyword form (`function name {}`), `local` scoping, direct function positional params (`$1`, `$@`, `$#`), and richer `$((...))` arithmetic operators are supported. | Medium | Still not a full shell grammar (no `select`, no nested/compound parser parity, no backtick command substitution or full bash heredoc escape semantics, no full bash function/parameter-expansion surface, and no full arithmetic-assignment grammar). | `Tests/BashTests/ParserAndFilesystemTests.swift`, `Tests/BashTests/SessionIntegrationTests.swift` | | `head` / `tail` | Line-count shorthand forms such as `head -100`, `tail -100`, `tail +100`, and attached short-option values like `-n100` are supported alongside the standard `-n` form. | Low | Still lacks full GNU signed-count parity such as interpreting negative `head -n` counts as "all but the last N" or `tail -c +N` byte-from-start semantics. | `Tests/BashTests/SessionIntegrationTests.swift`, `Tests/BashTests/CommandCoverageTests.swift` | -| `wget` | Basic `wget` emulation is available (`--version`, `-q`, `-O/--output-document`, URL fetch via in-process `curl` path). | Medium | No recursive download modes, robots handling, retry/progress parity, auth matrix, or full GNU `wget` flag compatibility. | `Tests/BashTests/SessionIntegrationTests.swift`, `Tests/BashTests/CommandCoverageTests.swift` | +| `curl` / `wget` | In-process `curl`/`wget` emulation supports `data:`, jailed `file:`, and HTTP(S) fetches, plus an optional host permission callback with per-session exact-request grants. | Medium | No recursive `wget` modes, robots handling, retry/progress parity, full auth/flag matrices, or richer built-in policy primitives beyond the host callback + session cache. | `Tests/BashTests/SessionIntegrationTests.swift`, `Tests/BashTests/CommandCoverageTests.swift` | | `python3` / `python` | Embedded CPython with strict shell-filesystem shims; supports `-c`, `-m`, script file/stdin execution, and core stdlib + filesystem interoperability. | Medium | Broader CLI flag parity, full stdlib/native-extension parity, packaging (`pip`) support, and richer compatibility with process APIs (intentionally blocked in strict mode). | `Tests/BashPythonTests/Python3CommandTests.swift`, `Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift` | From c15f252bb21ba734b39960f6d25d7081f051e4ae Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 15 Mar 2026 21:57:13 -0700 Subject: [PATCH 03/14] Network security updates --- README.md | 44 ++- Sources/Bash/BashSession.swift | 5 +- Sources/Bash/Commands/BuiltinCommand.swift | 20 +- Sources/Bash/Commands/NetworkCommands.swift | 13 +- Sources/Bash/Core/ShellExecutor.swift | 16 +- Sources/Bash/Support/Permissions.swift | 273 +++++++++++++++++- Sources/Bash/Support/Types.swift | 3 + Sources/BashCPythonBridge/BashCPythonBridge.c | 42 +++ .../include/BashCPythonBridge.h | 7 + Sources/BashGit/GitEngine.swift | 24 ++ Sources/BashPython/CPythonRuntime.swift | 177 ++++++++++++ Sources/BashPython/Python3Command.swift | 4 +- Sources/BashPython/PythonRuntime.swift | 8 +- Tests/BashGitTests/GitCommandTests.swift | 24 ++ Tests/BashGitTests/TestSupport.swift | 24 +- .../CPythonRuntimeIntegrationTests.swift | 35 +++ Tests/BashPythonTests/TestSupport.swift | 24 +- Tests/BashTests/SessionIntegrationTests.swift | 26 ++ Tests/BashTests/TestSupport.swift | 2 + docs/command-parity-gaps.md | 4 +- 20 files changed, 736 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 7d9ca40..dcb9aee 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Development of `Bash.swift` was approached very similarly to [just-bash](https:/ - [Quick Start](#quick-start) - [Public API](#public-api) - [How It Works](#how-it-works) +- [Security](#security) - [Filesystem Model](#filesystem-model) - [Implemented Commands](#implemented-commands) - [Eval Runner and Profiles](#eval-runner-and-profiles) @@ -126,6 +127,7 @@ Maintainer notes for the broader Apple runtime plan live in [`docs/cpython-apple Strict filesystem mode is enabled by default. Script-visible file APIs are shimmed through `ShellFilesystem`, so Python file operations share the same jailed root as shell commands. Blocked escape APIs include `subprocess`, `ctypes`, and process-spawn helpers like `os.system` / `os.popen` / `os.spawn*`. +`SessionOptions.networkPolicy` and `permissionHandler` also apply to Python socket connections, so host apps can enforce the same outbound rules across shell commands and embedded Python. `pip` and arbitrary native extension loading are non-goals in this runtime profile. Optional `git` registration: @@ -251,6 +253,20 @@ public enum PermissionDecision { } ``` +### `NetworkPolicy` + +```swift +public struct NetworkPolicy { + public static let unrestricted: NetworkPolicy + + public var allowedHosts: [String] + public var allowedURLPrefixes: [String] + public var denyPrivateRanges: Bool +} +``` + +Use `allowedHosts` for host-based allowlists that should also apply to `git` remotes and Python socket connections. Use `allowedURLPrefixes` for URL-aware tools like `curl`/`wget`. When both are empty, the built-in policy is unrestricted. + ### `SessionOptions` ```swift @@ -260,6 +276,7 @@ public struct SessionOptions { public var initialEnvironment: [String: String] public var enableGlobbing: Bool public var maxHistory: Int + public var networkPolicy: NetworkPolicy public var permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? public var secretPolicy: SecretHandlingPolicy public var secretResolver: (any SecretReferenceResolving)? @@ -273,21 +290,27 @@ Defaults: - `initialEnvironment`: `[:]` - `enableGlobbing`: `true` - `maxHistory`: `1000` +- `networkPolicy`: `NetworkPolicy.unrestricted` - `permissionHandler`: `nil` - `secretPolicy`: `.off` - `secretResolver`: `nil` - `secretOutputRedactor`: `DefaultSecretOutputRedactor()` -Use `permissionHandler` when the host app or agent needs explicit control over outbound permissions. Returning `.allow` grants the current request once, `.allowForSession` caches an exact-match request for the life of that `BashSession`, and `.deny(message:)` blocks it with a user-visible error. If you want broader or persistent memory across sessions, keep that policy in the host and decide what to return from the callback. +Use `networkPolicy` for built-in outbound rules such as private-range blocking and allowlists. Use `permissionHandler` when the host app or agent needs explicit control over outbound permissions after the built-in policy passes. Returning `.allow` grants the current request once, `.allowForSession` caches an exact-match request for the life of that `BashSession`, and `.deny(message:)` blocks it with a user-visible error. If you want broader or persistent memory across sessions, keep that policy in the host and decide what to return from the callback. -Example HTTP(S) permission gate: +Example built-in policy plus callback: ```swift let options = SessionOptions( + networkPolicy: NetworkPolicy( + allowedHosts: ["api.example.com"], + allowedURLPrefixes: ["https://api.example.com/v1/"], + denyPrivateRanges: true + ), permissionHandler: { request in switch request.kind { case let .network(network): - if network.url.hasPrefix("https://api.example.com/") { + if network.url.hasPrefix("https://api.example.com/v1/") { return .allowForSession } return .deny(message: "network access denied") @@ -318,6 +341,18 @@ Execution pipeline: `run(_:options:)` follows the same pipeline, but starts from temporary environment / cwd overrides and restores the session shell state afterward. +## Security + +`Bash.swift` is a practical execution environment, not a hardened security sandbox. The project is designed to keep command execution in-process, jail filesystem access to the configured root, and give the embedding app explicit control over sensitive surfaces such as secrets and outbound network access. That said, it should be treated as defense-in-depth for app and agent workflows, not as a guarantee that hostile code is safely contained. + +Current hardening layers include: +- Root-jail filesystem implementations plus null-byte path rejection. +- Optional `NetworkPolicy` rules (`denyPrivateRanges`, host allowlists, URL-prefix allowlists) and the host `permissionHandler`. +- Strict `BashPython` shims that block process/FFI escape APIs like `subprocess`, `ctypes`, and `os.system`. +- Secret-reference resolution/redaction policies that keep opaque references in model-visible flows by default. + +Security-sensitive embeddings should still assume the host app owns the real trust boundary. If you need durable user consent, domain reputation checks, persistent policy memory, stricter runtime isolation, or stronger resource limits, keep those controls in the host and use `BashSession` as one layer rather than the whole boundary. + ### Supported Shell Features - Quoting and escaping (`'...'`, `"..."`, `\\`) @@ -515,7 +550,8 @@ All implemented commands support `--help`. | `html-to-markdown` | `-b/--bullet `, `-c/--code `, `-r/--hr `, `--heading-style `; input from file or stdin; strips `script/style/footer` blocks; supports nested lists and Markdown table rendering | When `SessionOptions.secretPolicy` is `.resolveAndRedact` or `.strict`, `curl` resolves `secretref:v1:...` tokens in headers/body arguments and output redaction replaces resolved values with their reference tokens. -When `SessionOptions.permissionHandler` is set, `curl` and `wget` ask it before outbound HTTP(S) requests. `data:` and jailed `file:` URLs do not trigger the callback. +When `SessionOptions.networkPolicy` is set, `curl`/`wget`, `git clone` remotes, and `BashPython` socket connections enforce the same built-in allowlist/private-range rules. +When `SessionOptions.permissionHandler` is set, `curl` and `wget` ask it before outbound HTTP(S) requests, `git clone` asks it before remote clones, and `BashPython` asks it before socket connections. `data:` and jailed `file:` URLs do not trigger network checks. ## Command Behaviors and Notes diff --git a/Sources/Bash/BashSession.swift b/Sources/Bash/BashSession.swift index dd9856d..4f94bfa 100644 --- a/Sources/Bash/BashSession.swift +++ b/Sources/Bash/BashSession.swift @@ -237,7 +237,10 @@ public final actor BashSession { self.options = options filesystemStore = configuredFilesystem jobManager = ShellJobManager() - permissionAuthorizer = PermissionAuthorizer(handler: options.permissionHandler) + permissionAuthorizer = PermissionAuthorizer( + networkPolicy: options.networkPolicy, + handler: options.permissionHandler + ) commandRegistry = [:] shellFunctionStore = [:] diff --git a/Sources/Bash/Commands/BuiltinCommand.swift b/Sources/Bash/Commands/BuiltinCommand.swift index b5f5afb..eb5ae05 100644 --- a/Sources/Bash/Commands/BuiltinCommand.swift +++ b/Sources/Bash/Commands/BuiltinCommand.swift @@ -19,7 +19,7 @@ public struct CommandContext: Sendable { public var stderr: Data let secretTracker: SecretExposureTracker? let jobControl: (any ShellJobControlling)? - let permissionAuthorizer: PermissionAuthorizer + let permissionAuthorizer: any PermissionAuthorizing public init( commandName: String, @@ -75,7 +75,7 @@ public struct CommandContext: Sendable { stderr: Data = Data(), secretTracker: SecretExposureTracker?, jobControl: (any ShellJobControlling)? = nil, - permissionAuthorizer: PermissionAuthorizer = PermissionAuthorizer() + permissionAuthorizer: any PermissionAuthorizing = PermissionAuthorizer() ) { self.commandName = commandName self.arguments = arguments @@ -178,6 +178,22 @@ public struct CommandContext: Sendable { await permissionAuthorizer.authorize(request) } + public func requestNetworkPermission( + url: String, + method: String + ) async -> PermissionDecision { + await requestPermission( + PermissionRequest( + command: commandName, + kind: .network(NetworkPermissionRequest(url: url, method: method)) + ) + ) + } + + public var permissionDelegate: any PermissionAuthorizing { + permissionAuthorizer + } + public mutating func runSubcommand( _ argv: [String], stdin: Data? = nil diff --git a/Sources/Bash/Commands/NetworkCommands.swift b/Sources/Bash/Commands/NetworkCommands.swift index b46850a..a6741a5 100644 --- a/Sources/Bash/Commands/NetworkCommands.swift +++ b/Sources/Bash/Commands/NetworkCommands.swift @@ -1198,17 +1198,10 @@ struct CurlCommand: BuiltinCommand { context: inout CommandContext, options: Options ) async -> Int32? { - let request = PermissionRequest( - command: context.commandName, - kind: .network( - NetworkPermissionRequest( - url: url.absoluteString, - method: method - ) - ) + let decision = await context.requestNetworkPermission( + url: url.absoluteString, + method: method ) - - let decision = await context.requestPermission(request) if case let .deny(message) = decision { let reason = message ?? "network access denied: \(method) \(url.absoluteString)" return emitError( diff --git a/Sources/Bash/Core/ShellExecutor.swift b/Sources/Bash/Core/ShellExecutor.swift index 1bcfb91..c0ccb31 100644 --- a/Sources/Bash/Core/ShellExecutor.swift +++ b/Sources/Bash/Core/ShellExecutor.swift @@ -24,7 +24,7 @@ enum ShellExecutor { shellFunctions: [String: String], enableGlobbing: Bool, jobControl: (any ShellJobControlling)?, - permissionAuthorizer: PermissionAuthorizer, + permissionAuthorizer: any PermissionAuthorizing, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -161,7 +161,7 @@ enum ShellExecutor { shellFunctions: [String: String], enableGlobbing: Bool, jobControl: (any ShellJobControlling)?, - permissionAuthorizer: PermissionAuthorizer, + permissionAuthorizer: any PermissionAuthorizing, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -211,7 +211,7 @@ enum ShellExecutor { shellFunctions: [String: String], enableGlobbing: Bool, jobControl: (any ShellJobControlling)?, - permissionAuthorizer: PermissionAuthorizer, + permissionAuthorizer: any PermissionAuthorizing, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -449,7 +449,7 @@ enum ShellExecutor { shellFunctions: [String: String], enableGlobbing: Bool, jobControl: (any ShellJobControlling)?, - permissionAuthorizer: PermissionAuthorizer, + permissionAuthorizer: any PermissionAuthorizing, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -922,7 +922,7 @@ enum ShellExecutor { commandRegistry: [String: AnyBuiltinCommand], shellFunctions: [String: String], enableGlobbing: Bool, - permissionAuthorizer: PermissionAuthorizer, + permissionAuthorizer: any PermissionAuthorizing, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -962,7 +962,7 @@ enum ShellExecutor { commandRegistry: [String: AnyBuiltinCommand], shellFunctions: [String: String], enableGlobbing: Bool, - permissionAuthorizer: PermissionAuthorizer, + permissionAuthorizer: any PermissionAuthorizing, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -1143,7 +1143,7 @@ enum ShellExecutor { commandRegistry: [String: AnyBuiltinCommand], shellFunctions: [String: String], enableGlobbing: Bool, - permissionAuthorizer: PermissionAuthorizer, + permissionAuthorizer: any PermissionAuthorizing, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -1302,7 +1302,7 @@ enum ShellExecutor { commandRegistry: [String: AnyBuiltinCommand], shellFunctions: [String: String], enableGlobbing: Bool, - permissionAuthorizer: PermissionAuthorizer, + permissionAuthorizer: any PermissionAuthorizing, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, diff --git a/Sources/Bash/Support/Permissions.swift b/Sources/Bash/Support/Permissions.swift index 455f175..362514d 100644 --- a/Sources/Bash/Support/Permissions.swift +++ b/Sources/Bash/Support/Permissions.swift @@ -1,3 +1,11 @@ +import Foundation + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + public struct PermissionRequest: Sendable, Hashable { public enum Kind: Sendable, Hashable { case network(NetworkPermissionRequest) @@ -22,23 +30,61 @@ public struct NetworkPermissionRequest: Sendable, Hashable { } } +public struct NetworkPolicy: Sendable { + public static let unrestricted = NetworkPolicy() + + public var allowedHosts: [String] + public var allowedURLPrefixes: [String] + public var denyPrivateRanges: Bool + + public init( + allowedHosts: [String] = [], + allowedURLPrefixes: [String] = [], + denyPrivateRanges: Bool = false + ) { + self.allowedHosts = allowedHosts + self.allowedURLPrefixes = allowedURLPrefixes + self.denyPrivateRanges = denyPrivateRanges + } + + var hasAllowlist: Bool { + !allowedHosts.isEmpty || !allowedURLPrefixes.isEmpty + } +} + public enum PermissionDecision: Sendable { case allow case allowForSession case deny(message: String?) } -actor PermissionAuthorizer { +public protocol PermissionAuthorizing: Sendable { + func authorize(_ request: PermissionRequest) async -> PermissionDecision +} + +actor PermissionAuthorizer: PermissionAuthorizing { typealias Handler = @Sendable (PermissionRequest) async -> PermissionDecision + private let networkPolicy: NetworkPolicy private let handler: Handler? private var sessionAllows: Set = [] - init(handler: Handler? = nil) { + init( + networkPolicy: NetworkPolicy = .unrestricted, + handler: Handler? = nil + ) { + self.networkPolicy = networkPolicy self.handler = handler } func authorize(_ request: PermissionRequest) async -> PermissionDecision { + if let denial = PermissionPolicyEvaluator.denialMessage( + for: request, + networkPolicy: networkPolicy + ) { + return .deny(message: denial) + } + if sessionAllows.contains(request) { return .allow } @@ -56,3 +102,226 @@ actor PermissionAuthorizer { return decision } } + +private enum PermissionPolicyEvaluator { + static func denialMessage( + for request: PermissionRequest, + networkPolicy: NetworkPolicy + ) -> String? { + switch request.kind { + case let .network(networkRequest): + denialMessage(for: networkRequest, networkPolicy: networkPolicy) + } + } + + private static func denialMessage( + for request: NetworkPermissionRequest, + networkPolicy: NetworkPolicy + ) -> String? { + guard networkPolicy.denyPrivateRanges || networkPolicy.hasAllowlist else { + return nil + } + + let host = parsedHost(from: request.url) + + if networkPolicy.denyPrivateRanges, + let host, + hostTargetsPrivateRange(host) { + return "network access denied by policy: private network host '\(host)'" + } + + guard networkPolicy.hasAllowlist else { + return nil + } + + if networkPolicy.allowedURLPrefixes.contains(where: { request.url.hasPrefix($0) }) { + return nil + } + + if let host, + hostIsAllowed(host, allowedHosts: networkPolicy.allowedHosts) { + return nil + } + + return "network access denied by policy: '\(request.url)' is not in the network allowlist" + } + + private static func parsedHost(from urlString: String) -> String? { + URL(string: urlString)?.host?.trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + } + + private static func hostIsAllowed(_ host: String, allowedHosts: [String]) -> Bool { + let normalized = host.lowercased() + for candidate in allowedHosts { + let allowed = candidate.lowercased() + if normalized == allowed || normalized.hasSuffix(".\(allowed)") { + return true + } + } + return false + } + + private static func hostTargetsPrivateRange(_ host: String) -> Bool { + let normalized = host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + + if normalized == "localhost" + || normalized == "localhost." + || normalized.hasSuffix(".localhost") + || normalized.hasSuffix(".localhost.") { + return true + } + + if normalized.hasSuffix(".local") || normalized.hasSuffix(".home.arpa") { + return true + } + + if let ipv4 = parseIPv4Address(normalized) { + return isPrivateIPv4(ipv4) + } + + if let ipv6 = parseIPv6Address(normalized) { + return isPrivateIPv6(ipv6) + } + + for address in resolvedAddresses(for: normalized) { + switch address { + case let .ipv4(octets): + if isPrivateIPv4(octets) { + return true + } + case let .ipv6(bytes): + if isPrivateIPv6(bytes) { + return true + } + } + } + + return false + } + + private enum ResolvedAddress { + case ipv4([UInt8]) + case ipv6([UInt8]) + } + + private static func parseIPv4Address(_ host: String) -> [UInt8]? { + let parts = host.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { + return nil + } + + var octets: [UInt8] = [] + octets.reserveCapacity(4) + for part in parts { + guard let value = UInt8(part) else { + return nil + } + octets.append(value) + } + return octets + } + + private static func parseIPv6Address(_ host: String) -> [UInt8]? { + var storage = in6_addr() + let result = host.withCString { pointer in + inet_pton(AF_INET6, pointer, &storage) + } + guard result == 1 else { + return nil + } + return withUnsafeBytes(of: storage) { Array($0) } + } + + private static func resolvedAddresses(for host: String) -> [ResolvedAddress] { + var hints = addrinfo( + ai_flags: AI_ADDRCONFIG, + ai_family: AF_UNSPEC, + ai_socktype: SOCK_STREAM, + ai_protocol: IPPROTO_TCP, + ai_addrlen: 0, + ai_canonname: nil, + ai_addr: nil, + ai_next: nil + ) + + var results: UnsafeMutablePointer? + let status = host.withCString { pointer in + getaddrinfo(pointer, nil, &hints, &results) + } + guard status == 0, let results else { + return [] + } + defer { freeaddrinfo(results) } + + var addresses: [ResolvedAddress] = [] + var current: UnsafeMutablePointer? = results + while let entry = current { + let info = entry.pointee + if info.ai_family == AF_INET, let addr = info.ai_addr { + let value = addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { + $0.pointee.sin_addr + } + let octets = withUnsafeBytes(of: value.s_addr.bigEndian) { Array($0) } + addresses.append(.ipv4(octets)) + } else if info.ai_family == AF_INET6, let addr = info.ai_addr { + let value = addr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { + $0.pointee.sin6_addr + } + let bytes = withUnsafeBytes(of: value) { Array($0) } + addresses.append(.ipv6(bytes)) + } + current = info.ai_next + } + + return addresses + } + + private static func isPrivateIPv4(_ octets: [UInt8]) -> Bool { + guard octets.count == 4 else { + return false + } + + switch (octets[0], octets[1]) { + case (0, _): + return true + case (10, _): + return true + case (100, 64...127): + return true + case (127, _): + return true + case (169, 254): + return true + case (172, 16...31): + return true + case (192, 168): + return true + default: + return false + } + } + + private static func isPrivateIPv6(_ bytes: [UInt8]) -> Bool { + guard bytes.count == 16 else { + return false + } + + if bytes[0...14].allSatisfy({ $0 == 0 }) && bytes[15] == 1 { + return true + } + + if bytes[0] == 0xfc || bytes[0] == 0xfd { + return true + } + + if bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80 { + return true + } + + if bytes[0...9].allSatisfy({ $0 == 0 }) && bytes[10] == 0xff && bytes[11] == 0xff { + return isPrivateIPv4(Array(bytes[12...15])) + } + + return false + } +} diff --git a/Sources/Bash/Support/Types.swift b/Sources/Bash/Support/Types.swift index c7a3a10..e387d35 100644 --- a/Sources/Bash/Support/Types.swift +++ b/Sources/Bash/Support/Types.swift @@ -114,6 +114,7 @@ public struct SessionOptions: Sendable { public var initialEnvironment: [String: String] public var enableGlobbing: Bool public var maxHistory: Int + public var networkPolicy: NetworkPolicy public var permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? public var secretPolicy: SecretHandlingPolicy public var secretResolver: (any SecretReferenceResolving)? @@ -125,6 +126,7 @@ public struct SessionOptions: Sendable { initialEnvironment: [String: String] = [:], enableGlobbing: Bool = true, maxHistory: Int = 1_000, + networkPolicy: NetworkPolicy = .unrestricted, permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? = nil, secretPolicy: SecretHandlingPolicy = .off, secretResolver: (any SecretReferenceResolving)? = nil, @@ -135,6 +137,7 @@ public struct SessionOptions: Sendable { self.initialEnvironment = initialEnvironment self.enableGlobbing = enableGlobbing self.maxHistory = maxHistory + self.networkPolicy = networkPolicy self.permissionHandler = permissionHandler self.secretPolicy = secretPolicy self.secretResolver = secretResolver diff --git a/Sources/BashCPythonBridge/BashCPythonBridge.c b/Sources/BashCPythonBridge/BashCPythonBridge.c index 8492e1b..f69f5f6 100644 --- a/Sources/BashCPythonBridge/BashCPythonBridge.c +++ b/Sources/BashCPythonBridge/BashCPythonBridge.c @@ -18,6 +18,8 @@ struct BashCPythonRuntime { BashCPythonFSHandler fs_handler; void *fs_context; + BashCPythonNetworkHandler network_handler; + void *network_context; int initialized; char *bootstrap_script; }; @@ -123,8 +125,33 @@ static PyObject *bashswift_fs_call(PyObject *self, PyObject *args) { return result; } +static PyObject *bashswift_network_call(PyObject *self, PyObject *args) { + (void)self; + + const char *request_json = NULL; + if (!PyArg_ParseTuple(args, "s", &request_json)) { + return NULL; + } + + if (g_current_runtime == NULL || g_current_runtime->network_handler == NULL) { + PyErr_SetString(PyExc_RuntimeError, "network bridge is not active"); + return NULL; + } + + const char *response_json = g_current_runtime->network_handler(g_current_runtime->network_context, request_json); + if (response_json == NULL) { + PyErr_SetString(PyExc_RuntimeError, "network bridge returned no response"); + return NULL; + } + + PyObject *result = PyUnicode_FromString(response_json); + free((void *)response_json); + return result; +} + static PyMethodDef bashswift_host_methods[] = { {"fs_call", bashswift_fs_call, METH_VARARGS, "Perform a filesystem operation through the host bridge."}, + {"network_call", bashswift_network_call, METH_VARARGS, "Perform a network permission check through the host bridge."}, {NULL, NULL, 0, NULL} }; @@ -227,6 +254,8 @@ BashCPythonRuntime *bash_cpython_runtime_create(const char *bootstrap_script, ch runtime->initialized = 0; runtime->fs_handler = NULL; runtime->fs_context = NULL; + runtime->network_handler = NULL; + runtime->network_context = NULL; g_active_runtime_count += 1; return runtime; @@ -276,6 +305,19 @@ void bash_cpython_runtime_set_fs_handler( runtime->fs_context = context; } +void bash_cpython_runtime_set_network_handler( + BashCPythonRuntime *runtime, + BashCPythonNetworkHandler handler, + void *context +) { + if (runtime == NULL) { + return; + } + + runtime->network_handler = handler; + runtime->network_context = context; +} + char *bash_cpython_runtime_execute( BashCPythonRuntime *runtime, const char *request_json, diff --git a/Sources/BashCPythonBridge/include/BashCPythonBridge.h b/Sources/BashCPythonBridge/include/BashCPythonBridge.h index 28ab8f7..b80969e 100644 --- a/Sources/BashCPythonBridge/include/BashCPythonBridge.h +++ b/Sources/BashCPythonBridge/include/BashCPythonBridge.h @@ -8,6 +8,7 @@ extern "C" { #endif typedef const char *(*BashCPythonFSHandler)(void *context, const char *request_json); +typedef const char *(*BashCPythonNetworkHandler)(void *context, const char *request_json); typedef struct BashCPythonRuntime BashCPythonRuntime; @@ -22,6 +23,12 @@ void bash_cpython_runtime_set_fs_handler( void *context ); +void bash_cpython_runtime_set_network_handler( + BashCPythonRuntime *runtime, + BashCPythonNetworkHandler handler, + void *context +); + char *bash_cpython_runtime_execute( BashCPythonRuntime *runtime, const char *request_json, diff --git a/Sources/BashGit/GitEngine.swift b/Sources/BashGit/GitEngine.swift index 25af6a5..9259c5a 100644 --- a/Sources/BashGit/GitEngine.swift +++ b/Sources/BashGit/GitEngine.swift @@ -521,6 +521,15 @@ private enum GitEngineLibgit2 { private static func resolveCloneSource(repository: String, context: CommandContext) async throws -> CloneSource { if isRemoteRepository(repository) { + let remoteURL = normalizedRemoteRepositoryURL(repository) + let decision = await context.requestNetworkPermission( + url: remoteURL, + method: "CLONE" + ) + if case let .deny(message) = decision { + throw GitEngineError.runtime(message ?? "network access denied: CLONE \(remoteURL)") + } + return CloneSource( sourceURL: repository, projection: nil, @@ -598,6 +607,21 @@ private enum GitEngineLibgit2 { return repository[.. String { + if repository.contains("://") { + return repository + } + + if let colonIndex = repository.firstIndex(of: ":"), + repository[.. String { var message: String? var index = 0 diff --git a/Sources/BashPython/CPythonRuntime.swift b/Sources/BashPython/CPythonRuntime.swift index 345e012..7835eeb 100644 --- a/Sources/BashPython/CPythonRuntime.swift +++ b/Sources/BashPython/CPythonRuntime.swift @@ -53,9 +53,30 @@ private let cpythonFilesystemCallback: @convention(c) (UnsafeMutableRawPointer?, return UnsafePointer(pointer) } +private let cpythonNetworkCallback: @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?) -> UnsafePointer? = { + context, request in + guard let context, + let request + else { + guard let pointer = strdup("{\"ok\":false,\"error\":\"invalid network callback payload\"}") else { + return nil + } + return UnsafePointer(pointer) + } + + let bridge = Unmanaged.fromOpaque(context).takeUnretainedValue() + let requestJSON = String(cString: request) + let responseJSON = bridge.handle(requestJSON: requestJSON) + guard let pointer = strdup(responseJSON) else { + return nil + } + return UnsafePointer(pointer) +} + public actor CPythonRuntime: PythonRuntime { private let configuration: CPythonConfiguration private let filesystemBridge = CPythonFilesystemBridge() + private let networkBridge = CPythonNetworkBridge() private var runtime: OpaquePointer? private var initializationError: String? @@ -108,8 +129,13 @@ public actor CPythonRuntime: PythonRuntime { filesystem: filesystem, currentDirectory: request.currentDirectory ) + networkBridge.setContext( + commandName: request.commandName, + permissionAuthorizer: request.permissionAuthorizer + ) defer { filesystemBridge.clearContext() + networkBridge.clearContext() } let payload: [String: Any] = [ @@ -128,6 +154,8 @@ public actor CPythonRuntime: PythonRuntime { let bridgeContext = Unmanaged.passUnretained(filesystemBridge).toOpaque() bash_cpython_runtime_set_fs_handler(runtime, cpythonFilesystemCallback, bridgeContext) + let networkContext = Unmanaged.passUnretained(networkBridge).toOpaque() + bash_cpython_runtime_set_network_handler(runtime, cpythonNetworkCallback, networkContext) var errorPointer: UnsafeMutablePointer? let resultPointer = payloadJSON.withCString { payloadCString in @@ -481,6 +509,107 @@ private final class CPythonFilesystemBridge: @unchecked Sendable { } } +private final class CPythonNetworkBridge: @unchecked Sendable { + private let lock = NSLock() + private var commandName = "python3" + private var permissionAuthorizer: (any PermissionAuthorizing)? + + func setContext( + commandName: String, + permissionAuthorizer: (any PermissionAuthorizing)? + ) { + lock.lock() + defer { lock.unlock() } + self.commandName = commandName + self.permissionAuthorizer = permissionAuthorizer + } + + func clearContext() { + lock.lock() + defer { lock.unlock() } + commandName = "python3" + permissionAuthorizer = nil + } + + func handle(requestJSON: String) -> String { + guard let data = requestJSON.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return response(error: "invalid network bridge request") + } + + guard let url = object["url"] as? String, + let method = object["method"] as? String + else { + return response(error: "network bridge request missing url or method") + } + + let snapshot = snapshot() + guard let permissionAuthorizer = snapshot.permissionAuthorizer else { + return response(success: [:]) + } + + let request = PermissionRequest( + command: snapshot.commandName, + kind: .network(NetworkPermissionRequest(url: url, method: method)) + ) + + do { + let decision = try runBlocking { + await permissionAuthorizer.authorize(request) + } + if case let .deny(message) = decision { + return response(error: message ?? "network access denied: \(method) \(url)") + } + return response(success: [:]) + } catch { + return response(error: "\(error)") + } + } + + private func snapshot() -> (commandName: String, permissionAuthorizer: (any PermissionAuthorizing)?) { + lock.lock() + defer { lock.unlock() } + return (commandName, permissionAuthorizer) + } + + private func runBlocking(_ operation: @escaping @Sendable () async -> T) throws -> T { + let semaphore = DispatchSemaphore(value: 0) + let box = BlockingResultBox() + + Task.detached { + let value = await operation() + box.set(.success(value)) + semaphore.signal() + } + + semaphore.wait() + switch box.get() { + case let .success(value): + return value + case let .failure(error): + throw error + case .none: + throw CPythonRuntimeError.executionFailed("network permission check did not produce a result") + } + } + + private func response(success: [String: Any]) -> String { + var payload: [String: Any] = ["ok": true] + payload.merge(success) { _, rhs in rhs } + return jsonString(payload) + } + + private func response(error: String) -> String { + jsonString(["ok": false, "error": error]) + } + + private func jsonString(_ object: [String: Any]) -> String { + let data = (try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])) ?? Data("{}".utf8) + return String(decoding: data, as: UTF8.self) + } +} + private final class BlockingResultBox: @unchecked Sendable { private let lock = NSLock() private var storage: Result? @@ -509,6 +638,7 @@ import json import os import posixpath import runpy +import socket import stat as _stat import sys import tempfile as _tempfile @@ -516,6 +646,7 @@ import traceback import uuid from _bashswift_host import fs_call as _bashswift_fs_call_raw +from _bashswift_host import network_call as _bashswift_network_call_raw _BASHSWIFT_STATE = { 'cwd': '/', @@ -562,6 +693,39 @@ def _fs_call(op, payload=None): return response +def _network_call(url, method='CONNECT'): + request = { + 'url': str(url or ''), + 'method': str(method or 'CONNECT'), + } + raw = _bashswift_network_call_raw(json.dumps(request, sort_keys=True)) + response = json.loads(raw or '{}') + if not response.get('ok'): + message = response.get('error') or 'network access denied' + raise PermissionError(message) + return response + + +def _network_target_url(host, port=None, scheme='tcp'): + host = '' if host is None else str(host) + if ':' in host and not host.startswith('['): + host = f'[{host}]' + + if port is None: + return f'{scheme}://{host}/' + return f'{scheme}://{host}:{int(port)}/' + + +def _authorize_socket_target(address): + if isinstance(address, tuple) and address: + host = address[0] + port = address[1] if len(address) > 1 else None + else: + host = address + port = None + _network_call(_network_target_url(host, port), method='CONNECT') + + def _read_file_bytes(path): response = _fs_call('readFile', {'path': path}) return _b64decode(response.get('dataBase64', '')) @@ -1040,6 +1204,19 @@ def _patch_runtime(): os.spawnv = _blocked os.spawnvp = _blocked + _original_socket_connect = socket.socket.connect + _original_socket_connect_ex = socket.socket.connect_ex + def _socket_connect(self, address): + _authorize_socket_target(address) + return _original_socket_connect(self, address) + + def _socket_connect_ex(self, address): + _authorize_socket_target(address) + return _original_socket_connect_ex(self, address) + + socket.socket.connect = _socket_connect + socket.socket.connect_ex = _socket_connect_ex + _tempfile.gettempdir = lambda: '/tmp' def _mkdtemp(suffix='', prefix='tmp', dir=None): diff --git a/Sources/BashPython/Python3Command.swift b/Sources/BashPython/Python3Command.swift index 5b5a666..ed3de2a 100644 --- a/Sources/BashPython/Python3Command.swift +++ b/Sources/BashPython/Python3Command.swift @@ -88,13 +88,15 @@ public struct Python3Command: BuiltinCommand { } let request = PythonExecutionRequest( + commandName: context.commandName, mode: invocation.input.executionMode, source: source, scriptPath: scriptPath, arguments: invocation.scriptArgs, currentDirectory: context.currentDirectory, environment: context.environment, - stdin: String(decoding: context.stdin, as: UTF8.self) + stdin: String(decoding: context.stdin, as: UTF8.self), + permissionAuthorizer: context.permissionDelegate ) let runtime = await PythonRuntimeRegistry.shared.currentRuntime() diff --git a/Sources/BashPython/PythonRuntime.swift b/Sources/BashPython/PythonRuntime.swift index 5466d9a..63d3893 100644 --- a/Sources/BashPython/PythonRuntime.swift +++ b/Sources/BashPython/PythonRuntime.swift @@ -7,6 +7,7 @@ public enum PythonExecutionMode: String, Sendable { } public struct PythonExecutionRequest: Sendable { + public var commandName: String public var mode: PythonExecutionMode public var source: String public var scriptPath: String? @@ -14,16 +15,20 @@ public struct PythonExecutionRequest: Sendable { public var currentDirectory: String public var environment: [String: String] public var stdin: String + public var permissionAuthorizer: (any PermissionAuthorizing)? public init( + commandName: String, mode: PythonExecutionMode, source: String, scriptPath: String?, arguments: [String], currentDirectory: String, environment: [String: String], - stdin: String + stdin: String, + permissionAuthorizer: (any PermissionAuthorizing)? = nil ) { + self.commandName = commandName self.mode = mode self.source = source self.scriptPath = scriptPath @@ -31,6 +36,7 @@ public struct PythonExecutionRequest: Sendable { self.currentDirectory = currentDirectory self.environment = environment self.stdin = stdin + self.permissionAuthorizer = permissionAuthorizer } } diff --git a/Tests/BashGitTests/GitCommandTests.swift b/Tests/BashGitTests/GitCommandTests.swift index eef56cb..d3a6037 100644 --- a/Tests/BashGitTests/GitCommandTests.swift +++ b/Tests/BashGitTests/GitCommandTests.swift @@ -145,6 +145,30 @@ struct GitCommandTests { #expect(clone.stderrString.contains("already exists")) } + @Test("clone remote repository respects network policy") + func cloneRemoteRepositoryRespectsNetworkPolicy() async throws { + let (session, root) = try await GitTestSupport.makeReadWriteSession( + networkPolicy: NetworkPolicy(denyPrivateRanges: true) + ) + defer { GitTestSupport.removeDirectory(root) } + + let clone = await session.run("git clone https://127.0.0.1:1/repo.git") + #expect(clone.exitCode == 1) + #expect(clone.stderrString.contains("private network host")) + } + + @Test("clone ssh-style repository respects host allowlist") + func cloneSSHStyleRepositoryRespectsHostAllowlist() async throws { + let (session, root) = try await GitTestSupport.makeReadWriteSession( + networkPolicy: NetworkPolicy(allowedHosts: ["gitlab.com"]) + ) + defer { GitTestSupport.removeDirectory(root) } + + let clone = await session.run("git clone git@github.com:velos/Bash.swift.git") + #expect(clone.exitCode == 1) + #expect(clone.stderrString.contains("not in the network allowlist")) + } + @Test("rev-parse outside repository is fatal") func revParseOutsideRepository() async throws { let (session, root) = try await GitTestSupport.makeReadWriteSession() diff --git a/Tests/BashGitTests/TestSupport.swift b/Tests/BashGitTests/TestSupport.swift index 90b4d70..f38544f 100644 --- a/Tests/BashGitTests/TestSupport.swift +++ b/Tests/BashGitTests/TestSupport.swift @@ -10,19 +10,35 @@ enum GitTestSupport { return url } - static func makeReadWriteSession() async throws -> (session: BashSession, root: URL) { + static func makeReadWriteSession( + networkPolicy: NetworkPolicy = .unrestricted, + permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? = nil + ) async throws -> (session: BashSession, root: URL) { let root = try makeTempDirectory() let session = try await BashSession( rootDirectory: root, - options: SessionOptions(filesystem: ReadWriteFilesystem(), layout: .unixLike) + options: SessionOptions( + filesystem: ReadWriteFilesystem(), + layout: .unixLike, + networkPolicy: networkPolicy, + permissionHandler: permissionHandler + ) ) await session.registerGit() return (session, root) } - static func makeInMemorySession() async throws -> BashSession { + static func makeInMemorySession( + networkPolicy: NetworkPolicy = .unrestricted, + permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? = nil + ) async throws -> BashSession { let session = try await BashSession( - options: SessionOptions(filesystem: InMemoryFilesystem(), layout: .unixLike) + options: SessionOptions( + filesystem: InMemoryFilesystem(), + layout: .unixLike, + networkPolicy: networkPolicy, + permissionHandler: permissionHandler + ) ) await session.registerGit() return session diff --git a/Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift b/Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift index 8cbaaf8..3e0565a 100644 --- a/Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift +++ b/Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift @@ -111,6 +111,41 @@ struct CPythonRuntimeIntegrationTests { #expect(osSystem.stderrString.contains("PermissionError")) } + @Test("network policy blocks private socket targets") + @BashPythonTestActor + func networkPolicyBlocksPrivateSocketTargets() async throws { + let (session, root) = try await PythonTestSupport.makeSession( + networkPolicy: NetworkPolicy(denyPrivateRanges: true) + ) + defer { PythonTestSupport.removeDirectory(root) } + + let result = await session.run(#"python3 -c "import socket; socket.socket().connect(('127.0.0.1', 80))""#) + #expect(result.exitCode == 1) + #expect(result.stderrString.contains("private network host")) + } + + @Test("python network checks reuse host callback after policy passes") + @BashPythonTestActor + func pythonNetworkChecksReuseHostCallbackAfterPolicyPasses() async throws { + let (session, root) = try await PythonTestSupport.makeSession( + networkPolicy: NetworkPolicy(allowedHosts: ["1.1.1.1"]), + permissionHandler: { request in + switch request.kind { + case let .network(network): + if network.url.hasPrefix("tcp://1.1.1.1:80/") { + return .deny(message: "blocked by callback") + } + return .allow + } + } + ) + defer { PythonTestSupport.removeDirectory(root) } + + let result = await session.run(#"python3 -c "import socket; socket.socket().connect(('1.1.1.1', 80))""#) + #expect(result.exitCode == 1) + #expect(result.stderrString.contains("blocked by callback")) + } + @Test("in-memory filesystem path works") @BashPythonTestActor func inMemoryFilesystemWorks() async throws { diff --git a/Tests/BashPythonTests/TestSupport.swift b/Tests/BashPythonTests/TestSupport.swift index 1a02c2f..5ed3f28 100644 --- a/Tests/BashPythonTests/TestSupport.swift +++ b/Tests/BashPythonTests/TestSupport.swift @@ -15,15 +15,31 @@ enum PythonTestSupport { return url } - static func makeSession() async throws -> (session: BashSession, root: URL) { + static func makeSession( + networkPolicy: NetworkPolicy = .unrestricted, + permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? = nil + ) async throws -> (session: BashSession, root: URL) { let root = try makeTempDirectory() - let session = try await BashSession(rootDirectory: root) + let session = try await BashSession( + rootDirectory: root, + options: SessionOptions( + networkPolicy: networkPolicy, + permissionHandler: permissionHandler + ) + ) await session.registerPython() return (session, root) } - static func makeInMemorySession() async throws -> BashSession { - let options = SessionOptions(filesystem: InMemoryFilesystem()) + static func makeInMemorySession( + networkPolicy: NetworkPolicy = .unrestricted, + permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? = nil + ) async throws -> BashSession { + let options = SessionOptions( + filesystem: InMemoryFilesystem(), + networkPolicy: networkPolicy, + permissionHandler: permissionHandler + ) let session = try await BashSession(options: options) await session.registerPython() return session diff --git a/Tests/BashTests/SessionIntegrationTests.swift b/Tests/BashTests/SessionIntegrationTests.swift index 570c334..94b1e12 100644 --- a/Tests/BashTests/SessionIntegrationTests.swift +++ b/Tests/BashTests/SessionIntegrationTests.swift @@ -1632,6 +1632,32 @@ struct SessionIntegrationTests { #expect(requests.isEmpty) } + @Test("curl network policy can deny private ranges") + func curlNetworkPolicyCanDenyPrivateRanges() async throws { + let (session, root) = try await TestSupport.makeSession( + networkPolicy: NetworkPolicy(denyPrivateRanges: true) + ) + defer { TestSupport.removeDirectory(root) } + + let result = await session.run("curl http://127.0.0.1:1") + #expect(result.exitCode == 1) + #expect(result.stderrString.contains("private network host")) + } + + @Test("curl network policy can deny urls outside allowlist") + func curlNetworkPolicyCanDenyURLsOutsideAllowlist() async throws { + let (session, root) = try await TestSupport.makeSession( + networkPolicy: NetworkPolicy( + allowedURLPrefixes: ["https://api.example.com/"] + ) + ) + defer { TestSupport.removeDirectory(root) } + + let result = await session.run("curl https://example.com") + #expect(result.exitCode == 1) + #expect(result.stderrString.contains("not in the network allowlist")) + } + @Test("html-to-markdown command parity chunk") func htmlToMarkdownCommandParityChunk() async throws { let (session, root) = try await TestSupport.makeSession() diff --git a/Tests/BashTests/TestSupport.swift b/Tests/BashTests/TestSupport.swift index 19125ff..ee756f7 100644 --- a/Tests/BashTests/TestSupport.swift +++ b/Tests/BashTests/TestSupport.swift @@ -13,6 +13,7 @@ enum TestSupport { filesystem: (any ShellFilesystem)? = nil, layout: SessionLayout = .unixLike, enableGlobbing: Bool = true, + networkPolicy: NetworkPolicy = .unrestricted, permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? = nil ) async throws -> (session: BashSession, root: URL) { let root = try makeTempDirectory() @@ -22,6 +23,7 @@ enum TestSupport { initialEnvironment: [:], enableGlobbing: enableGlobbing, maxHistory: 1_000, + networkPolicy: networkPolicy, permissionHandler: permissionHandler ) diff --git a/docs/command-parity-gaps.md b/docs/command-parity-gaps.md index 58183f7..1edeed5 100644 --- a/docs/command-parity-gaps.md +++ b/docs/command-parity-gaps.md @@ -7,5 +7,5 @@ This document tracks major command parity gaps relative to `just-bash` and shell | Job control (`&`, `$!`, `jobs`, `fg`, `wait`, `ps`, `kill`) | Background execution, pseudo-PID tracking, process listing, and signal-style termination are supported for in-process commands with buffered stdout/stderr handoff. | Medium | No stopped-job state transitions (`bg`, `disown`, `SIGTSTP`/`SIGCONT`) and no true host-process/TTY semantics. | `Tests/BashTests/ParserAndFilesystemTests.swift`, `Tests/BashTests/SessionIntegrationTests.swift` | | Shell language (`$(...)`, `for`, functions) | Command substitution, here-documents via `<<` / `<<-`, unquoted heredoc expansion for the shell’s supported `$VAR` / `${...}` / `$((...))` / `$(...)` features, `if/elif/else`, `while`, `until`, `case`, `for ... in ...`, C-style `for ((...))`, function keyword form (`function name {}`), `local` scoping, direct function positional params (`$1`, `$@`, `$#`), and richer `$((...))` arithmetic operators are supported. | Medium | Still not a full shell grammar (no `select`, no nested/compound parser parity, no backtick command substitution or full bash heredoc escape semantics, no full bash function/parameter-expansion surface, and no full arithmetic-assignment grammar). | `Tests/BashTests/ParserAndFilesystemTests.swift`, `Tests/BashTests/SessionIntegrationTests.swift` | | `head` / `tail` | Line-count shorthand forms such as `head -100`, `tail -100`, `tail +100`, and attached short-option values like `-n100` are supported alongside the standard `-n` form. | Low | Still lacks full GNU signed-count parity such as interpreting negative `head -n` counts as "all but the last N" or `tail -c +N` byte-from-start semantics. | `Tests/BashTests/SessionIntegrationTests.swift`, `Tests/BashTests/CommandCoverageTests.swift` | -| `curl` / `wget` | In-process `curl`/`wget` emulation supports `data:`, jailed `file:`, and HTTP(S) fetches, plus an optional host permission callback with per-session exact-request grants. | Medium | No recursive `wget` modes, robots handling, retry/progress parity, full auth/flag matrices, or richer built-in policy primitives beyond the host callback + session cache. | `Tests/BashTests/SessionIntegrationTests.swift`, `Tests/BashTests/CommandCoverageTests.swift` | -| `python3` / `python` | Embedded CPython with strict shell-filesystem shims; supports `-c`, `-m`, script file/stdin execution, and core stdlib + filesystem interoperability. | Medium | Broader CLI flag parity, full stdlib/native-extension parity, packaging (`pip`) support, and richer compatibility with process APIs (intentionally blocked in strict mode). | `Tests/BashPythonTests/Python3CommandTests.swift`, `Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift` | +| `curl` / `wget` | In-process `curl`/`wget` emulation supports `data:`, jailed `file:`, and HTTP(S) fetches, plus built-in `NetworkPolicy` controls (`denyPrivateRanges`, host allowlists, URL-prefix allowlists) and an optional host permission callback with per-session exact-request grants. | Medium | No recursive `wget` modes, robots handling, retry/progress parity, or full auth/flag compatibility. | `Tests/BashTests/SessionIntegrationTests.swift`, `Tests/BashTests/CommandCoverageTests.swift` | +| `python3` / `python` | Embedded CPython with strict shell-filesystem shims; supports `-c`, `-m`, script file/stdin execution, core stdlib + filesystem interoperability, and reuses the session network policy/permission path for socket connections. | Medium | Broader CLI flag parity, full stdlib/native-extension parity, packaging (`pip`) support, richer compatibility with process APIs (intentionally blocked in strict mode), and deeper coverage for higher-level networking libraries. | `Tests/BashPythonTests/Python3CommandTests.swift`, `Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift` | From 1cc456e9efa8281b3e4a412bda0df95f6c5e54ec Mon Sep 17 00:00:00 2001 From: Zac White Date: Thu, 19 Mar 2026 12:29:36 -0700 Subject: [PATCH 04/14] Better organization --- Sources/Bash/BashSession+ControlFlow.swift | 1652 +++++++++++ Sources/Bash/BashSession+Expansion.swift | 774 +++++ Sources/Bash/BashSession+SyntaxHelpers.swift | 311 ++ Sources/Bash/BashSession.swift | 2743 +----------------- 4 files changed, 2744 insertions(+), 2736 deletions(-) create mode 100644 Sources/Bash/BashSession+ControlFlow.swift create mode 100644 Sources/Bash/BashSession+Expansion.swift create mode 100644 Sources/Bash/BashSession+SyntaxHelpers.swift diff --git a/Sources/Bash/BashSession+ControlFlow.swift b/Sources/Bash/BashSession+ControlFlow.swift new file mode 100644 index 0000000..6f5ee58 --- /dev/null +++ b/Sources/Bash/BashSession+ControlFlow.swift @@ -0,0 +1,1652 @@ +import Foundation + +extension BashSession { + struct SimpleForLoop { + enum Kind { + case list(variableName: String, values: [String]) + case cStyle(initializer: String, condition: String, increment: String) + } + + var kind: Kind + var body: String + var trailingAction: TrailingAction + } + + enum SimpleForLoopParseResult { + case notForLoop + case success(SimpleForLoop) + case failure(ShellError) + } + + struct IfBranch { + var condition: String + var body: String + } + + struct SimpleIfBlock { + var branches: [IfBranch] + var elseBody: String? + var trailingAction: TrailingAction + } + + enum SimpleIfBlockParseResult { + case notIfBlock + case success(SimpleIfBlock) + case failure(ShellError) + } + + struct SimpleWhileLoop { + var leadingCommands: String? + var condition: String + var isUntil: Bool + var body: String + var trailingAction: TrailingAction + } + + enum SimpleWhileLoopParseResult { + case notWhileLoop + case success(SimpleWhileLoop) + case failure(ShellError) + } + + struct SimpleCaseArm { + var patterns: [String] + var body: String + } + + struct SimpleCaseBlock { + var leadingCommands: String? + var subject: String + var arms: [SimpleCaseArm] + var trailingAction: TrailingAction + } + + enum SimpleCaseBlockParseResult { + case notCaseBlock + case success(SimpleCaseBlock) + case failure(ShellError) + } + + func executeSimpleForLoopIfPresent( + commandLine: String, + stdin: Data, + prefixedStderr: Data + ) async -> CommandResult? { + let parsedLoop = parseSimpleForLoop(commandLine) + switch parsedLoop { + case .notForLoop: + return nil + case let .failure(error): + var stderr = prefixedStderr + stderr.append(Data("\(error)\n".utf8)) + return CommandResult(stdout: Data(), stderr: stderr, exitCode: 2) + case let .success(loop): + let parsedBody: ParsedLine + do { + parsedBody = try ShellParser.parse(loop.body) + } catch { + var stderr = prefixedStderr + stderr.append(Data("\(error)\n".utf8)) + return CommandResult(stdout: Data(), stderr: stderr, exitCode: 2) + } + + var combinedOut = Data() + var combinedErr = Data() + var lastExitCode: Int32 = 0 + + switch loop.kind { + case let .list(variableName, values): + for value in values { + environmentStore[variableName] = value + let execution = await executeParsedLine( + parsedLine: parsedBody, + stdin: stdin, + currentDirectory: currentDirectoryStore, + environment: environmentStore, + shellFunctions: shellFunctionStore, + jobControl: jobManager + ) + + currentDirectoryStore = execution.currentDirectory + environmentStore = execution.environment + environmentStore["PWD"] = currentDirectoryStore + + combinedOut.append(execution.result.stdout) + combinedErr.append(execution.result.stderr) + lastExitCode = execution.result.exitCode + } + case let .cStyle(initializer, condition, increment): + if let initializerError = executeCStyleArithmeticStatement(initializer) { + var stderr = prefixedStderr + stderr.append(Data("\(initializerError)\n".utf8)) + return CommandResult(stdout: Data(), stderr: stderr, exitCode: 2) + } + + var iterations = 0 + while true { + iterations += 1 + if iterations > 10_000 { + combinedErr.append(Data("for: exceeded max iterations\n".utf8)) + lastExitCode = 2 + break + } + + let shouldContinue: Bool + if condition.isEmpty { + shouldContinue = true + } else { + let evaluated = ArithmeticEvaluator.evaluate( + condition, + environment: environmentStore + ) ?? 0 + shouldContinue = evaluated != 0 + } + + if !shouldContinue { + break + } + + let execution = await executeParsedLine( + parsedLine: parsedBody, + stdin: stdin, + currentDirectory: currentDirectoryStore, + environment: environmentStore, + shellFunctions: shellFunctionStore, + jobControl: jobManager + ) + + currentDirectoryStore = execution.currentDirectory + environmentStore = execution.environment + environmentStore["PWD"] = currentDirectoryStore + + combinedOut.append(execution.result.stdout) + combinedErr.append(execution.result.stderr) + lastExitCode = execution.result.exitCode + + if let incrementError = executeCStyleArithmeticStatement(increment) { + combinedErr.append(Data("\(incrementError)\n".utf8)) + lastExitCode = 2 + break + } + } + } + + var result = CommandResult( + stdout: combinedOut, + stderr: combinedErr, + exitCode: lastExitCode + ) + await applyTrailingAction(loop.trailingAction, to: &result) + mergePrefixedStderr(prefixedStderr, into: &result) + + return result + } + } + + func parseSimpleForLoop(_ commandLine: String) -> SimpleForLoopParseResult { + var index = commandLine.startIndex + Self.skipWhitespace(in: commandLine, index: &index) + + guard Self.consumeKeyword( + "for", + in: commandLine, + index: &index + ) else { + return .notForLoop + } + + Self.skipWhitespace(in: commandLine, index: &index) + let loopKind: SimpleForLoop.Kind + + if commandLine[index...].hasPrefix("((") { + guard let cStyle = Self.parseCStyleForHeader(commandLine, index: &index) else { + return .failure(.parserError("for: expected C-style header '((init;cond;inc))'")) + } + + Self.skipWhitespace(in: commandLine, index: &index) + guard let doMarker = Self.findDelimitedKeyword( + "do", + in: commandLine, + from: index + ) else { + return .failure(.parserError("for: expected 'do'")) + } + + index = doMarker.afterKeywordIndex + loopKind = .cStyle( + initializer: cStyle.initializer, + condition: cStyle.condition, + increment: cStyle.increment + ) + } else { + guard let variableName = Self.readIdentifier(in: commandLine, index: &index) else { + return .failure(.parserError("for: expected loop variable")) + } + + Self.skipWhitespace(in: commandLine, index: &index) + guard Self.consumeKeyword("in", in: commandLine, index: &index) else { + return .failure(.parserError("for: expected 'in'")) + } + + Self.skipWhitespace(in: commandLine, index: &index) + guard let valuesMarker = Self.findDelimitedKeyword( + "do", + in: commandLine, + from: index + ) else { + return .failure(.parserError("for: expected 'do'")) + } + + let rawValues = String(commandLine[index.. CommandResult? { + let parsedIf = parseSimpleIfBlock(commandLine) + switch parsedIf { + case .notIfBlock: + return nil + case let .failure(error): + var stderr = prefixedStderr + stderr.append(Data("\(error)\n".utf8)) + return CommandResult(stdout: Data(), stderr: stderr, exitCode: 2) + case let .success(ifBlock): + var combinedOut = Data() + var combinedErr = Data() + var lastExitCode: Int32 = 0 + + var selectedBody: String? + for branch in ifBlock.branches { + let conditionResult = await executeConditionalExpression( + branch.condition, + stdin: stdin + ) + combinedOut.append(conditionResult.stdout) + combinedErr.append(conditionResult.stderr) + lastExitCode = conditionResult.exitCode + + if conditionResult.exitCode == 0 { + selectedBody = branch.body + break + } + } + + if selectedBody == nil { + selectedBody = ifBlock.elseBody + if selectedBody == nil { + lastExitCode = 0 + } + } + + if let selectedBody, !selectedBody.isEmpty { + let bodyResult = await executeStandardCommandLine( + selectedBody, + stdin: stdin + ) + combinedOut.append(bodyResult.stdout) + combinedErr.append(bodyResult.stderr) + lastExitCode = bodyResult.exitCode + } + + var result = CommandResult( + stdout: combinedOut, + stderr: combinedErr, + exitCode: lastExitCode + ) + await applyTrailingAction(ifBlock.trailingAction, to: &result) + mergePrefixedStderr(prefixedStderr, into: &result) + return result + } + } + + func parseSimpleIfBlock(_ commandLine: String) -> SimpleIfBlockParseResult { + var index = commandLine.startIndex + Self.skipWhitespace(in: commandLine, index: &index) + + guard Self.consumeKeyword("if", in: commandLine, index: &index) else { + return .notIfBlock + } + + Self.skipWhitespace(in: commandLine, index: &index) + guard let thenMarker = Self.findDelimitedKeyword( + "then", + in: commandLine, + from: index + ) else { + return .failure(.parserError("if: expected 'then'")) + } + + let condition = String(commandLine[index.. CommandResult? { + await executeSimpleConditionalLoopIfPresent( + parseSimpleWhileLoop(commandLine), + stdin: stdin, + prefixedStderr: prefixedStderr + ) + } + + func executeSimpleUntilLoopIfPresent( + commandLine: String, + stdin: Data, + prefixedStderr: Data + ) async -> CommandResult? { + await executeSimpleConditionalLoopIfPresent( + parseSimpleUntilLoop(commandLine), + stdin: stdin, + prefixedStderr: prefixedStderr + ) + } + + func executeSimpleConditionalLoopIfPresent( + _ parsedLoop: SimpleWhileLoopParseResult, + stdin: Data, + prefixedStderr: Data + ) async -> CommandResult? { + switch parsedLoop { + case .notWhileLoop: + return nil + case let .failure(error): + var stderr = prefixedStderr + stderr.append(Data("\(error)\n".utf8)) + return CommandResult(stdout: Data(), stderr: stderr, exitCode: 2) + case let .success(loop): + let parsedBody: ParsedLine + do { + parsedBody = try ShellParser.parse(loop.body) + } catch { + var stderr = prefixedStderr + stderr.append(Data("\(error)\n".utf8)) + return CommandResult(stdout: Data(), stderr: stderr, exitCode: 2) + } + + var combinedOut = Data() + var combinedErr = Data() + var lastExitCode: Int32 = 0 + var didRunBody = false + + if let leadingCommands = loop.leadingCommands, + !leadingCommands.isEmpty { + let leadingResult = await executeStandardCommandLine( + leadingCommands, + stdin: stdin + ) + combinedOut.append(leadingResult.stdout) + combinedErr.append(leadingResult.stderr) + lastExitCode = leadingResult.exitCode + } + + var iterations = 0 + while true { + iterations += 1 + if iterations > 10_000 { + let loopName = loop.isUntil ? "until" : "while" + combinedErr.append(Data("\(loopName): exceeded max iterations\n".utf8)) + lastExitCode = 2 + break + } + + let conditionResult = await executeConditionalExpression( + loop.condition, + stdin: stdin + ) + combinedOut.append(conditionResult.stdout) + combinedErr.append(conditionResult.stderr) + + let conditionSucceeded = conditionResult.exitCode == 0 + let shouldRunBody = loop.isUntil ? !conditionSucceeded : conditionSucceeded + + if !shouldRunBody { + if !loop.isUntil && conditionResult.exitCode > 1, !didRunBody { + lastExitCode = conditionResult.exitCode + } else if !didRunBody { + lastExitCode = 0 + } + break + } + + let bodyExecution = await executeParsedLine( + parsedLine: parsedBody, + stdin: stdin, + currentDirectory: currentDirectoryStore, + environment: environmentStore, + shellFunctions: shellFunctionStore, + jobControl: jobManager + ) + currentDirectoryStore = bodyExecution.currentDirectory + environmentStore = bodyExecution.environment + environmentStore["PWD"] = currentDirectoryStore + + combinedOut.append(bodyExecution.result.stdout) + combinedErr.append(bodyExecution.result.stderr) + lastExitCode = bodyExecution.result.exitCode + didRunBody = true + } + + var result = CommandResult( + stdout: combinedOut, + stderr: combinedErr, + exitCode: lastExitCode + ) + await applyTrailingAction(loop.trailingAction, to: &result) + mergePrefixedStderr(prefixedStderr, into: &result) + return result + } + } + + func parseSimpleWhileLoop(_ commandLine: String) -> SimpleWhileLoopParseResult { + parseSimpleConditionalLoop( + commandLine, + keyword: "while", + isUntil: false + ) + } + + func parseSimpleUntilLoop(_ commandLine: String) -> SimpleWhileLoopParseResult { + parseSimpleConditionalLoop( + commandLine, + keyword: "until", + isUntil: true + ) + } + + func parseSimpleConditionalLoop( + _ commandLine: String, + keyword: String, + isUntil: Bool + ) -> SimpleWhileLoopParseResult { + var start = commandLine.startIndex + Self.skipWhitespace(in: commandLine, index: &start) + + if commandLine[start...].hasPrefix(keyword) { + return parseConditionalLoopClause( + String(commandLine[start...]), + keyword: keyword, + isUntil: isUntil, + leadingCommands: nil + ) + } + + guard let marker = Self.findDelimitedKeyword( + keyword, + in: commandLine, + from: start + ) else { + return .notWhileLoop + } + + let prefix = String(commandLine[start.. SimpleWhileLoopParseResult { + var index = loopClause.startIndex + Self.skipWhitespace(in: loopClause, index: &index) + guard Self.consumeKeyword(keyword, in: loopClause, index: &index) else { + return .notWhileLoop + } + + Self.skipWhitespace(in: loopClause, index: &index) + guard let doMarker = Self.findDelimitedKeyword( + "do", + in: loopClause, + from: index + ) else { + return .failure(.parserError("\(keyword): expected 'do'")) + } + + let condition = String(loopClause[index.. CommandResult? { + let parsedCase = parseSimpleCaseBlock(commandLine) + switch parsedCase { + case .notCaseBlock: + return nil + case let .failure(error): + var stderr = prefixedStderr + stderr.append(Data("\(error)\n".utf8)) + return CommandResult(stdout: Data(), stderr: stderr, exitCode: 2) + case let .success(caseBlock): + var combinedOut = Data() + var combinedErr = Data() + var lastExitCode: Int32 = 0 + + if let leadingCommands = caseBlock.leadingCommands, + !leadingCommands.isEmpty { + let leadingResult = await executeStandardCommandLine( + leadingCommands, + stdin: stdin + ) + combinedOut.append(leadingResult.stdout) + combinedErr.append(leadingResult.stderr) + lastExitCode = leadingResult.exitCode + } + + let subject = Self.evaluateCaseWord( + caseBlock.subject, + environment: environmentStore + ) + var selectedBody: String? + for arm in caseBlock.arms { + if arm.patterns.contains(where: { Self.casePatternMatches($0, value: subject, environment: environmentStore) }) { + selectedBody = arm.body + break + } + } + + if let selectedBody, !selectedBody.isEmpty { + let bodyResult = await executeStandardCommandLine( + selectedBody, + stdin: stdin + ) + combinedOut.append(bodyResult.stdout) + combinedErr.append(bodyResult.stderr) + lastExitCode = bodyResult.exitCode + } else { + lastExitCode = 0 + } + + var result = CommandResult( + stdout: combinedOut, + stderr: combinedErr, + exitCode: lastExitCode + ) + await applyTrailingAction(caseBlock.trailingAction, to: &result) + mergePrefixedStderr(prefixedStderr, into: &result) + return result + } + } + + func parseSimpleCaseBlock(_ commandLine: String) -> SimpleCaseBlockParseResult { + var start = commandLine.startIndex + Self.skipWhitespace(in: commandLine, index: &start) + + if commandLine[start...].hasPrefix("case") { + return parseCaseClause( + String(commandLine[start...]), + leadingCommands: nil + ) + } + + guard let marker = Self.findDelimitedKeyword( + "case", + in: commandLine, + from: start + ) else { + return .notCaseBlock + } + + let prefix = String(commandLine[start.. SimpleCaseBlockParseResult { + var index = clause.startIndex + Self.skipWhitespace(in: clause, index: &index) + + guard Self.consumeKeyword("case", in: clause, index: &index) else { + return .notCaseBlock + } + + Self.skipWhitespace(in: clause, index: &index) + guard let inRange = Self.findKeywordTokenRange( + "in", + in: clause, + from: index + ) else { + return .failure(.parserError("case: expected 'in'")) + } + + let subject = String(clause[index.. CommandResult { + if let testResult = await evaluateTestConditionIfPresent(condition) { + return testResult + } + return await executeStandardCommandLine(condition, stdin: stdin) + } + + func evaluateTestConditionIfPresent(_ condition: String) async -> CommandResult? { + let tokens: [LexToken] + do { + tokens = try ShellLexer.tokenize(condition) + } catch { + return CommandResult( + stdout: Data(), + stderr: Data("\(error)\n".utf8), + exitCode: 2 + ) + } + + var words: [String] = [] + for token in tokens { + guard case let .word(word) = token else { + return nil + } + words.append(Self.expandWord(word, environment: environmentStore)) + } + + guard let first = words.first else { + return nil + } + + var expression = words + if first == "test" { + expression.removeFirst() + } else if first == "[" { + guard expression.last == "]" else { + return CommandResult( + stdout: Data(), + stderr: Data("test: missing ']'\n".utf8), + exitCode: 2 + ) + } + expression.removeFirst() + expression.removeLast() + } else { + return nil + } + + return await evaluateTestExpression(expression) + } + + func evaluateTestExpression(_ expression: [String]) async -> CommandResult { + if expression.isEmpty { + return CommandResult(stdout: Data(), stderr: Data(), exitCode: 1) + } + + if expression.count == 1 { + let isTrue = !expression[0].isEmpty + return CommandResult( + stdout: Data(), + stderr: Data(), + exitCode: isTrue ? 0 : 1 + ) + } + + if expression.count == 2 { + let op = expression[0] + let value = expression[1] + + switch op { + case "-n": + return CommandResult( + stdout: Data(), + stderr: Data(), + exitCode: value.isEmpty ? 1 : 0 + ) + case "-z": + return CommandResult( + stdout: Data(), + stderr: Data(), + exitCode: value.isEmpty ? 0 : 1 + ) + case "-e", "-f", "-d": + let path = PathUtils.normalize( + path: value, + currentDirectory: currentDirectoryStore + ) + guard await filesystemStore.exists(path: path) else { + return CommandResult(stdout: Data(), stderr: Data(), exitCode: 1) + } + + guard let info = try? await filesystemStore.stat(path: path) else { + return CommandResult(stdout: Data(), stderr: Data(), exitCode: 1) + } + + let passed: Bool + switch op { + case "-e": + passed = true + case "-f": + passed = !info.isDirectory + case "-d": + passed = info.isDirectory + default: + passed = false + } + return CommandResult( + stdout: Data(), + stderr: Data(), + exitCode: passed ? 0 : 1 + ) + default: + return CommandResult( + stdout: Data(), + stderr: Data("test: unsupported expression\n".utf8), + exitCode: 2 + ) + } + } + + if expression.count == 3 { + let lhs = expression[0] + let op = expression[1] + let rhs = expression[2] + + switch op { + case "=", "==": + return CommandResult( + stdout: Data(), + stderr: Data(), + exitCode: lhs == rhs ? 0 : 1 + ) + case "!=": + return CommandResult( + stdout: Data(), + stderr: Data(), + exitCode: lhs != rhs ? 0 : 1 + ) + case "-eq", "-ne", "-lt", "-le", "-gt", "-ge": + guard let leftValue = Int(lhs), let rightValue = Int(rhs) else { + return CommandResult( + stdout: Data(), + stderr: Data("test: integer expression expected\n".utf8), + exitCode: 2 + ) + } + let passed: Bool + switch op { + case "-eq": + passed = leftValue == rightValue + case "-ne": + passed = leftValue != rightValue + case "-lt": + passed = leftValue < rightValue + case "-le": + passed = leftValue <= rightValue + case "-gt": + passed = leftValue > rightValue + case "-ge": + passed = leftValue >= rightValue + default: + passed = false + } + return CommandResult( + stdout: Data(), + stderr: Data(), + exitCode: passed ? 0 : 1 + ) + default: + return CommandResult( + stdout: Data(), + stderr: Data("test: unsupported expression\n".utf8), + exitCode: 2 + ) + } + } + + return CommandResult( + stdout: Data(), + stderr: Data("test: unsupported expression\n".utf8), + exitCode: 2 + ) + } + + func applyTrailingAction( + _ action: TrailingAction, + to result: inout CommandResult + ) async { + switch action { + case .none: + return + case let .redirections(redirections): + await applyRedirections(redirections, to: &result) + case let .pipeline(pipeline): + do { + let parsed = try ShellParser.parse(pipeline) + let pipelineExecution = await executeParsedLine( + parsedLine: parsed, + stdin: result.stdout, + currentDirectory: currentDirectoryStore, + environment: environmentStore, + shellFunctions: shellFunctionStore, + jobControl: jobManager + ) + currentDirectoryStore = pipelineExecution.currentDirectory + environmentStore = pipelineExecution.environment + environmentStore["PWD"] = currentDirectoryStore + + var mergedStderr = result.stderr + mergedStderr.append(pipelineExecution.result.stderr) + result = CommandResult( + stdout: pipelineExecution.result.stdout, + stderr: mergedStderr, + exitCode: pipelineExecution.result.exitCode + ) + } catch { + result.stdout.removeAll(keepingCapacity: true) + result.stderr.append(Data("\(error)\n".utf8)) + result.exitCode = 2 + } + } + } + + func mergePrefixedStderr(_ prefixedStderr: Data, into result: inout CommandResult) { + guard !prefixedStderr.isEmpty else { + return + } + + var merged = prefixedStderr + merged.append(result.stderr) + result.stderr = merged + } + + func applyRedirections( + _ redirections: [Redirection], + to result: inout CommandResult + ) async { + for redirection in redirections { + switch redirection.type { + case .stdin: + continue + case .stderrToStdout: + result.stdout.append(result.stderr) + result.stderr.removeAll(keepingCapacity: true) + case .stdoutTruncate, .stdoutAppend: + guard let targetWord = redirection.target else { continue } + let target = Self.expandWord( + targetWord, + environment: environmentStore + ) + let path = PathUtils.normalize( + path: target, + currentDirectory: currentDirectoryStore + ) + do { + try await filesystemStore.writeFile( + path: path, + data: result.stdout, + append: redirection.type == .stdoutAppend + ) + result.stdout.removeAll(keepingCapacity: true) + } catch { + result.stderr.append(Data("\(target): \(error)\n".utf8)) + result.exitCode = 1 + } + case .stderrTruncate, .stderrAppend: + guard let targetWord = redirection.target else { continue } + let target = Self.expandWord( + targetWord, + environment: environmentStore + ) + let path = PathUtils.normalize( + path: target, + currentDirectory: currentDirectoryStore + ) + do { + try await filesystemStore.writeFile( + path: path, + data: result.stderr, + append: redirection.type == .stderrAppend + ) + result.stderr.removeAll(keepingCapacity: true) + } catch { + result.stderr.append(Data("\(target): \(error)\n".utf8)) + result.exitCode = 1 + } + case .stdoutAndErrTruncate, .stdoutAndErrAppend: + guard let targetWord = redirection.target else { continue } + let target = Self.expandWord( + targetWord, + environment: environmentStore + ) + let path = PathUtils.normalize( + path: target, + currentDirectory: currentDirectoryStore + ) + var combined = Data() + combined.append(result.stdout) + combined.append(result.stderr) + do { + try await filesystemStore.writeFile( + path: path, + data: combined, + append: redirection.type == .stdoutAndErrAppend + ) + result.stdout.removeAll(keepingCapacity: true) + result.stderr.removeAll(keepingCapacity: true) + } catch { + result.stderr.append(Data("\(target): \(error)\n".utf8)) + result.exitCode = 1 + } + } + } + } + + static func parseLoopValues( + _ rawValues: String, + environment: [String: String] + ) throws -> [String] { + let tokens = try ShellLexer.tokenize(rawValues) + var values: [String] = [] + for token in tokens { + guard case let .word(word) = token else { + throw ShellError.parserError("for: unsupported loop value syntax") + } + values.append(expandWord(word, environment: environment)) + } + return values + } + + static func parseCStyleForHeader( + _ commandLine: String, + index: inout String.Index + ) -> (initializer: String, condition: String, increment: String)? { + guard Self.consumeLiteral("(", in: commandLine, index: &index), + Self.consumeLiteral("(", in: commandLine, index: &index) else { + return nil + } + + let secondOpen = commandLine.index(before: index) + guard let capture = captureBalancedDoubleParentheses( + in: commandLine, + secondOpen: secondOpen + ) else { + return nil + } + + let components = splitCStyleForComponents(capture.content) + guard components.count == 3 else { + return nil + } + + index = capture.endIndex + return ( + initializer: components[0].trimmingCharacters(in: .whitespacesAndNewlines), + condition: components[1].trimmingCharacters(in: .whitespacesAndNewlines), + increment: components[2].trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + static func captureBalancedDoubleParentheses( + in string: String, + secondOpen: String.Index + ) -> (content: String, endIndex: String.Index)? { + var depth = 1 + var cursor = string.index(after: secondOpen) + let contentStart = cursor + + while cursor < string.endIndex { + if string[cursor] == "(" { + let next = string.index(after: cursor) + if next < string.endIndex, string[next] == "(" { + depth += 1 + cursor = string.index(after: next) + continue + } + } else if string[cursor] == ")" { + let next = string.index(after: cursor) + if next < string.endIndex, string[next] == ")" { + depth -= 1 + if depth == 0 { + return ( + content: String(string[contentStart.. [String] { + var components: [String] = [] + var current = "" + var depth = 0 + var quote: QuoteKind = .none + var index = value.startIndex + + while index < value.endIndex { + let character = value[index] + + if character == "\\", quote != .single { + current.append(character) + let next = value.index(after: index) + if next < value.endIndex { + current.append(value[next]) + index = value.index(after: next) + } else { + index = next + } + continue + } + + if character == "'", quote != .double { + quote = quote == .single ? .none : .single + current.append(character) + index = value.index(after: index) + continue + } + + if character == "\"", quote != .single { + quote = quote == .double ? .none : .double + current.append(character) + index = value.index(after: index) + continue + } + + if quote == .none { + if character == "(" { + depth += 1 + } else if character == ")" { + depth = max(0, depth - 1) + } else if character == ";", depth == 0 { + components.append(current) + current = "" + index = value.index(after: index) + continue + } + } + + current.append(character) + index = value.index(after: index) + } + + components.append(current) + return components + } + + func executeCStyleArithmeticStatement(_ statement: String) -> ShellError? { + let trimmed = statement.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + + if trimmed.hasSuffix("++") || trimmed.hasSuffix("--") { + let suffixLength = 2 + let end = trimmed.index(trimmed.endIndex, offsetBy: -suffixLength) + let name = String(trimmed[.. [SimpleCaseArm] { + let trimmed = rawArms.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return [] + } + + var arms: [SimpleCaseArm] = [] + var index = trimmed.startIndex + + while index < trimmed.endIndex { + while index < trimmed.endIndex && + (trimmed[index].isWhitespace || trimmed[index] == ";") { + index = trimmed.index(after: index) + } + guard index < trimmed.endIndex else { + break + } + + guard let closeParen = findUnquotedCharacter( + ")", + in: trimmed, + from: index + ) else { + throw ShellError.parserError("case: expected ')' in pattern arm") + } + + let patternChunk = String(trimmed[index.. String { + do { + let tokens = try ShellLexer.tokenize(raw) + let words = tokens.compactMap { token -> String? in + guard case let .word(word) = token else { + return nil + } + return expandWord(word, environment: environment) + } + if words.isEmpty { + return raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + return words.joined(separator: " ") + } catch { + return expandVariables( + in: raw.trimmingCharacters(in: .whitespacesAndNewlines), + environment: environment + ) + } + } + + static func casePatternMatches( + _ rawPattern: String, + value: String, + environment: [String: String] + ) -> Bool { + let expanded = evaluateCaseWord(rawPattern, environment: environment) + guard let regex = try? NSRegularExpression(pattern: PathUtils.globToRegex(expanded)) else { + return expanded == value + } + + let range = NSRange(value.startIndex.. [String] { + var parts: [String] = [] + var current = "" + var quote: QuoteKind = .none + var index = value.startIndex + + while index < value.endIndex { + let character = value[index] + + if character == "\\", quote != .single { + current.append(character) + let next = value.index(after: index) + if next < value.endIndex { + current.append(value[next]) + index = value.index(after: next) + } else { + index = next + } + continue + } + + if character == "'", quote != .double { + quote = quote == .single ? .none : .single + current.append(character) + index = value.index(after: index) + continue + } + + if character == "\"", quote != .single { + quote = quote == .double ? .none : .double + current.append(character) + index = value.index(after: index) + continue + } + + if quote == .none, character == "|" { + parts.append(current) + current = "" + index = value.index(after: index) + continue + } + + current.append(character) + index = value.index(after: index) + } + + parts.append(current) + return parts + } + + static func findUnquotedCharacter( + _ target: Character, + in value: String, + from start: String.Index + ) -> String.Index? { + var quote: QuoteKind = .none + var index = start + + while index < value.endIndex { + let character = value[index] + + if character == "\\", quote != .single { + let next = value.index(after: index) + if next < value.endIndex { + index = value.index(after: next) + } else { + index = next + } + continue + } + + if character == "'", quote != .double { + quote = quote == .single ? .none : .single + index = value.index(after: index) + continue + } + + if character == "\"", quote != .single { + quote = quote == .double ? .none : .double + index = value.index(after: index) + continue + } + + if quote == .none, character == target { + return index + } + + index = value.index(after: index) + } + + return nil + } + + static func findUnquotedDoubleSemicolon( + in value: String, + from start: String.Index + ) -> Range? { + var quote: QuoteKind = .none + var index = start + + while index < value.endIndex { + let character = value[index] + + if character == "\\", quote != .single { + let next = value.index(after: index) + if next < value.endIndex { + index = value.index(after: next) + } else { + index = next + } + continue + } + + if character == "'", quote != .double { + quote = quote == .single ? .none : .single + index = value.index(after: index) + continue + } + + if character == "\"", quote != .single { + quote = quote == .double ? .none : .double + index = value.index(after: index) + continue + } + + if quote == .none, character == ";" { + let next = value.index(after: index) + if next < value.endIndex, value[next] == ";" { + return index.. CommandSubstitutionOutcome { + var output = "" + var stderr = Data() + var quote: QuoteKind = .none + var index = commandLine.startIndex + var pendingHereDocuments: [PendingHereDocument] = [] + + while index < commandLine.endIndex { + let character = commandLine[index] + + if character == "\\", quote != .single { + let next = commandLine.index(after: index) + output.append(character) + if next < commandLine.endIndex { + output.append(commandLine[next]) + index = commandLine.index(after: next) + } else { + index = next + } + continue + } + + if character == "'", quote != .double { + quote = quote == .single ? .none : .single + output.append(character) + index = commandLine.index(after: index) + continue + } + + if character == "\"", quote != .single { + quote = quote == .double ? .none : .double + output.append(character) + index = commandLine.index(after: index) + continue + } + + if quote == .none, + commandLine[index...].hasPrefix("<<"), + let hereDocument = Self.captureHereDocumentDeclaration(in: commandLine, from: index) { + output.append(contentsOf: commandLine[index.. CommandSubstitutionOutcome { + let nested = await expandCommandSubstitutions(in: command) + if let error = nested.error { + return CommandSubstitutionOutcome( + commandLine: "", + stderr: nested.stderr, + error: error + ) + } + + let parsed: ParsedLine + do { + parsed = try ShellParser.parse(nested.commandLine) + } catch let shellError as ShellError { + return CommandSubstitutionOutcome( + commandLine: "", + stderr: nested.stderr, + error: shellError + ) + } catch { + return CommandSubstitutionOutcome( + commandLine: "", + stderr: nested.stderr, + error: .parserError("\(error)") + ) + } + + let execution = await executeParsedLine( + parsedLine: parsed, + stdin: Data(), + currentDirectory: currentDirectoryStore, + environment: environmentStore, + shellFunctions: shellFunctionStore, + jobControl: nil + ) + + var stderr = nested.stderr + stderr.append(execution.result.stderr) + + let replacement = Self.trimmingTrailingNewlines( + from: execution.result.stdoutString + ) + return CommandSubstitutionOutcome( + commandLine: replacement, + stderr: stderr, + error: nil + ) + } + + func parseAndRegisterFunctionDefinitions( + in commandLine: String + ) -> FunctionDefinitionParseOutcome { + var functionStore = shellFunctionStore + var parsed = Self.parseFunctionDefinitions( + in: commandLine, + functionStore: &functionStore + ) + + if parsed.error == nil, + parsed.remaining == commandLine, + let marker = Self.findDelimitedKeyword( + "function", + in: commandLine, + from: commandLine.startIndex + ) { + let prefix = String(commandLine[.. (content: String, endIndex: String.Index) { + let openIndex = commandLine.index(after: dollarIndex) + var index = commandLine.index(after: openIndex) + let contentStart = index + var depth = 1 + var quote: QuoteKind = .none + + while index < commandLine.endIndex { + let character = commandLine[index] + + if character == "\\", quote != .single { + let next = commandLine.index(after: index) + if next < commandLine.endIndex { + index = commandLine.index(after: next) + } else { + index = next + } + continue + } + + if character == "'", quote != .double { + quote = quote == .single ? .none : .single + index = commandLine.index(after: index) + continue + } + + if character == "\"", quote != .single { + quote = quote == .double ? .none : .double + index = commandLine.index(after: index) + continue + } + + if quote == .none { + if character == "(" { + depth += 1 + } else if character == ")" { + depth -= 1 + if depth == 0 { + let content = String(commandLine[contentStart.. (raw: String, endIndex: String.Index)? { + let open = commandLine.index(after: dollarIndex) + guard open < commandLine.endIndex, commandLine[open] == "(" else { + return nil + } + + let secondOpen = commandLine.index(after: open) + guard secondOpen < commandLine.endIndex, commandLine[secondOpen] == "(" else { + return nil + } + + var depth = 1 + var cursor = commandLine.index(after: secondOpen) + + while cursor < commandLine.endIndex { + if commandLine[cursor] == "(" { + let next = commandLine.index(after: cursor) + if next < commandLine.endIndex, commandLine[next] == "(" { + depth += 1 + cursor = commandLine.index(after: next) + continue + } + } else if commandLine[cursor] == ")" { + let next = commandLine.index(after: cursor) + if next < commandLine.endIndex, commandLine[next] == ")" { + depth -= 1 + if depth == 0 { + let end = commandLine.index(after: next) + return (raw: String(commandLine[dollarIndex.. (delimiter: String, stripsLeadingTabs: Bool, endIndex: String.Index)? { + let stripsLeadingTabs: Bool + let indexOffset: Int + + if commandLine[operatorIndex...].hasPrefix("<<-") { + stripsLeadingTabs = true + indexOffset = 3 + } else { + stripsLeadingTabs = false + indexOffset = 2 + } + + var index = commandLine.index(operatorIndex, offsetBy: indexOffset) + + while index < commandLine.endIndex, + commandLine[index].isWhitespace, + commandLine[index] != "\n" { + index = commandLine.index(after: index) + } + + guard index < commandLine.endIndex, commandLine[index] != "\n" else { + return nil + } + + var delimiter = "" + var quote: QuoteKind = .none + var consumedAny = false + + while index < commandLine.endIndex { + let character = commandLine[index] + + if quote == .none, character.isWhitespace { + break + } + + if character == "'", quote != .double { + quote = quote == .single ? .none : .single + consumedAny = true + index = commandLine.index(after: index) + continue + } + + if character == "\"", quote != .single { + quote = quote == .double ? .none : .double + consumedAny = true + index = commandLine.index(after: index) + continue + } + + if character == "\\", quote != .single { + let next = commandLine.index(after: index) + if next < commandLine.endIndex { + delimiter.append(commandLine[next]) + index = commandLine.index(after: next) + } else { + delimiter.append(character) + index = next + } + consumedAny = true + continue + } + + delimiter.append(character) + consumedAny = true + index = commandLine.index(after: index) + } + + guard consumedAny, quote == .none else { + return nil + } + + return (delimiter: delimiter, stripsLeadingTabs: stripsLeadingTabs, endIndex: index) + } + + static func captureHereDocumentBodiesVerbatim( + in commandLine: String, + from startIndex: String.Index, + hereDocuments: [PendingHereDocument] + ) throws -> (raw: String, endIndex: String.Index) { + var raw = "" + var index = startIndex + + for hereDocument in hereDocuments { + var matched = false + + while index < commandLine.endIndex { + let lineStart = index + while index < commandLine.endIndex, commandLine[index] != "\n" { + index = commandLine.index(after: index) + } + + let line = String(commandLine[lineStart.. String { + String(line.drop { $0 == "\t" }) + } + + static func parseFunctionDefinitions( + in commandLine: String, + functionStore: inout [String: String] + ) -> FunctionDefinitionParseOutcome { + var index = commandLine.startIndex + Self.skipWhitespace(in: commandLine, index: &index) + var parsedAny = false + + while index < commandLine.endIndex { + let definitionStart = index + + let hasFunctionKeyword: Bool + let functionName: String + + if Self.consumeKeyword("function", in: commandLine, index: &index) { + hasFunctionKeyword = true + Self.skipWhitespace(in: commandLine, index: &index) + guard let parsedName = Self.readIdentifier(in: commandLine, index: &index) else { + return FunctionDefinitionParseOutcome( + remaining: commandLine, + error: .parserError("function: expected function name") + ) + } + functionName = parsedName + } else { + hasFunctionKeyword = false + guard let parsedName = Self.readIdentifier(in: commandLine, index: &index) else { + let remaining = String(commandLine[definitionStart...]) + .trimmingCharacters(in: .whitespacesAndNewlines) + return FunctionDefinitionParseOutcome( + remaining: parsedAny ? remaining : commandLine, + error: nil + ) + } + functionName = parsedName + } + + Self.skipWhitespace(in: commandLine, index: &index) + var hasParenthesizedSignature = false + if Self.consumeLiteral("(", in: commandLine, index: &index) { + hasParenthesizedSignature = true + Self.skipWhitespace(in: commandLine, index: &index) + guard Self.consumeLiteral(")", in: commandLine, index: &index) else { + if hasFunctionKeyword { + return FunctionDefinitionParseOutcome( + remaining: commandLine, + error: .parserError("function \(functionName): expected ')'")) + } + + let remaining = String(commandLine[definitionStart...]) + .trimmingCharacters(in: .whitespacesAndNewlines) + return FunctionDefinitionParseOutcome( + remaining: parsedAny ? remaining : commandLine, + error: nil + ) + } + Self.skipWhitespace(in: commandLine, index: &index) + } else if !hasFunctionKeyword { + let remaining = String(commandLine[definitionStart...]) + .trimmingCharacters(in: .whitespacesAndNewlines) + return FunctionDefinitionParseOutcome( + remaining: parsedAny ? remaining : commandLine, + error: nil + ) + } + + guard index < commandLine.endIndex, commandLine[index] == "{" else { + if hasFunctionKeyword || hasParenthesizedSignature { + return FunctionDefinitionParseOutcome( + remaining: commandLine, + error: .parserError("function \(functionName): expected '{'")) + } + + let remaining = String(commandLine[definitionStart...]) + .trimmingCharacters(in: .whitespacesAndNewlines) + return FunctionDefinitionParseOutcome( + remaining: parsedAny ? remaining : commandLine, + error: nil + ) + } + + do { + let braceCapture = try captureBalancedBraces( + in: commandLine, + from: index + ) + let body = String(commandLine[braceCapture.contentRange]) + .trimmingCharacters(in: .whitespacesAndNewlines) + functionStore[functionName] = body + parsedAny = true + index = braceCapture.endIndex + } catch let shellError as ShellError { + return FunctionDefinitionParseOutcome( + remaining: commandLine, + error: shellError + ) + } catch { + return FunctionDefinitionParseOutcome( + remaining: commandLine, + error: .parserError("\(error)") + ) + } + + let boundary = index + Self.skipWhitespace(in: commandLine, index: &index) + if index == commandLine.endIndex { + return FunctionDefinitionParseOutcome( + remaining: "", + error: nil + ) + } + + if commandLine[index] == ";" { + index = commandLine.index(after: index) + Self.skipWhitespace(in: commandLine, index: &index) + if index == commandLine.endIndex { + return FunctionDefinitionParseOutcome( + remaining: "", + error: nil + ) + } + continue + } + + let remaining = String(commandLine[boundary...]) + .trimmingCharacters(in: .whitespacesAndNewlines) + return FunctionDefinitionParseOutcome( + remaining: remaining, + error: nil + ) + } + + return FunctionDefinitionParseOutcome( + remaining: parsedAny ? "" : commandLine, + error: nil + ) + } + + static func captureBalancedBraces( + in commandLine: String, + from openBraceIndex: String.Index + ) throws -> (contentRange: Range, endIndex: String.Index) { + var index = commandLine.index(after: openBraceIndex) + let contentStart = index + var depth = 1 + var quote: QuoteKind = .none + + while index < commandLine.endIndex { + let character = commandLine[index] + + if character == "\\", quote != .single { + let next = commandLine.index(after: index) + if next < commandLine.endIndex { + index = commandLine.index(after: next) + } else { + index = next + } + continue + } + + if character == "'", quote != .double { + quote = quote == .single ? .none : .single + index = commandLine.index(after: index) + continue + } + + if character == "\"", quote != .single { + quote = quote == .double ? .none : .double + index = commandLine.index(after: index) + continue + } + + if quote == .none { + if character == "{" { + depth += 1 + } else if character == "}" { + depth -= 1 + if depth == 0 { + return ( + contentRange: contentStart.. String { + var output = value + while output.hasSuffix("\n") { + output.removeLast() + } + return output + } + + static func expandWord( + _ word: ShellWord, + environment: [String: String] + ) -> String { + var output = "" + for part in word.parts { + switch part.quote { + case .single: + output.append(part.text) + case .none, .double: + output.append(expandVariables(in: part.text, environment: environment)) + } + } + return output + } + + static func expandVariables( + in string: String, + environment: [String: String] + ) -> String { + var result = "" + var index = string.startIndex + + func readIdentifier(startingAt start: String.Index) -> (String, String.Index) { + var cursor = start + var value = "" + while cursor < string.endIndex { + let character = string[cursor] + if character.isLetter || character.isNumber || character == "_" { + value.append(character) + cursor = string.index(after: cursor) + } else { + break + } + } + return (value, cursor) + } + + while index < string.endIndex { + let character = string[index] + guard character == "$" else { + result.append(character) + index = string.index(after: index) + continue + } + + let next = string.index(after: index) + guard next < string.endIndex else { + result.append("$") + break + } + + if string[next] == "!" { + result += environment["!"] ?? "" + index = string.index(after: next) + continue + } + + if string[next] == "@" || string[next] == "*" || string[next] == "#" { + result += environment[String(string[next])] ?? "" + index = string.index(after: next) + continue + } + + if string[next] == "{" { + guard let close = string[next...].firstIndex(of: "}") else { + result.append("$") + index = next + continue + } + + let contentStart = string.index(after: next) + let content = String(string[contentStart.. DelimitedKeywordMatch? { + var quote: QuoteKind = .none + var index = startIndex + let endIndex = end ?? commandLine.endIndex + + while index < endIndex { + let character = commandLine[index] + + if character == "\\", quote != .single { + let next = commandLine.index(after: index) + if next < endIndex { + index = commandLine.index(after: next) + } else { + index = next + } + continue + } + + if character == "'", quote != .double { + quote = quote == .single ? .none : .single + index = commandLine.index(after: index) + continue + } + + if character == "\"", quote != .single { + quote = quote == .double ? .none : .double + index = commandLine.index(after: index) + continue + } + + if quote == .none, character == ";" || character == "\n" { + var cursor = commandLine.index(after: index) + while cursor < endIndex, commandLine[cursor].isWhitespace { + cursor = commandLine.index(after: cursor) + } + guard cursor < endIndex else { + return nil + } + + guard commandLine[cursor...].hasPrefix(keyword) else { + index = commandLine.index(after: index) + continue + } + + let afterKeyword = commandLine.index( + cursor, + offsetBy: keyword.count + ) + if afterKeyword < commandLine.endIndex, + Self.isIdentifierCharacter(commandLine[afterKeyword]) { + index = commandLine.index(after: index) + continue + } + + return DelimitedKeywordMatch( + separatorIndex: index, + keywordIndex: cursor, + afterKeywordIndex: afterKeyword + ) + } + + index = commandLine.index(after: index) + } + + return nil + } + + static func findFirstDelimitedKeyword( + _ keywords: [String], + in commandLine: String, + from startIndex: String.Index, + end: String.Index? = nil + ) -> (keyword: String, match: DelimitedKeywordMatch)? { + var best: (keyword: String, match: DelimitedKeywordMatch)? + for keyword in keywords { + guard let match = findDelimitedKeyword( + keyword, + in: commandLine, + from: startIndex, + end: end + ) else { + continue + } + + if let currentBest = best { + if match.separatorIndex < currentBest.match.separatorIndex { + best = (keyword, match) + } + } else { + best = (keyword, match) + } + } + return best + } + + static func findKeywordTokenRange( + _ keyword: String, + in value: String, + from start: String.Index + ) -> Range? { + var quote: QuoteKind = .none + var index = start + + while index < value.endIndex { + let character = value[index] + + if character == "\\", quote != .single { + let next = value.index(after: index) + if next < value.endIndex { + index = value.index(after: next) + } else { + index = next + } + continue + } + + if character == "'", quote != .double { + quote = quote == .single ? .none : .single + index = value.index(after: index) + continue + } + + if character == "\"", quote != .single { + quote = quote == .double ? .none : .double + index = value.index(after: index) + continue + } + + if quote == .none, value[index...].hasPrefix(keyword) { + let afterKeyword = value.index(index, offsetBy: keyword.count) + let beforeBoundary: Bool + if index == value.startIndex { + beforeBoundary = true + } else { + let previous = value[value.index(before: index)] + beforeBoundary = isKeywordBoundaryCharacter(previous) + } + + let afterBoundary: Bool + if afterKeyword == value.endIndex { + afterBoundary = true + } else { + afterBoundary = isKeywordBoundaryCharacter(value[afterKeyword]) + } + + if beforeBoundary, afterBoundary { + return index.. Bool { + character.isWhitespace || character == ";" || character == "(" || character == ")" + } + + static func parseTrailingAction( + from trailing: String, + context: String + ) -> Result { + let trimmed = trailing.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return .success(.none) + } + + if trimmed.hasPrefix("|") { + let tail = String(trimmed.dropFirst()) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !tail.isEmpty else { + return .failure(.parserError("\(context): expected command after '|'")) + } + return .success(.pipeline(tail)) + } + + switch parseRedirections(from: trimmed, context: context) { + case let .success(redirections): + return .success(.redirections(redirections)) + case let .failure(error): + return .failure(error) + } + } + + static func parseRedirections( + from trailing: String, + context: String + ) -> Result<[Redirection], ShellError> { + let trimmed = trailing.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return .success([]) + } + + do { + let parsed = try ShellParser.parse("true \(trimmed)") + guard parsed.segments.count == 1, + let segment = parsed.segments.first, + segment.connector == nil, + segment.pipeline.count == 1, + !segment.runInBackground, + segment.pipeline[0].words.count == 1, + segment.pipeline[0].words[0].rawValue == "true" else { + return .failure( + .parserError("\(context): unsupported trailing syntax") + ) + } + return .success(segment.pipeline[0].redirections) + } catch let shellError as ShellError { + return .failure(shellError) + } catch { + return .failure(.parserError("\(error)")) + } + } + + static func skipWhitespace( + in commandLine: String, + index: inout String.Index + ) { + while index < commandLine.endIndex, commandLine[index].isWhitespace { + index = commandLine.index(after: index) + } + } + + static func readIdentifier( + in commandLine: String, + index: inout String.Index + ) -> String? { + guard index < commandLine.endIndex else { + return nil + } + + let first = commandLine[index] + guard first == "_" || first.isLetter else { + return nil + } + + var value = String(first) + index = commandLine.index(after: index) + while index < commandLine.endIndex, + isIdentifierCharacter(commandLine[index]) { + value.append(commandLine[index]) + index = commandLine.index(after: index) + } + return value + } + + static func consumeLiteral( + _ literal: Character, + in commandLine: String, + index: inout String.Index + ) -> Bool { + guard index < commandLine.endIndex, + commandLine[index] == literal else { + return false + } + index = commandLine.index(after: index) + return true + } + + static func consumeKeyword( + _ keyword: String, + in commandLine: String, + index: inout String.Index + ) -> Bool { + guard commandLine[index...].hasPrefix(keyword) else { + return false + } + + let end = commandLine.index(index, offsetBy: keyword.count) + if end < commandLine.endIndex, + isIdentifierCharacter(commandLine[end]) { + return false + } + + index = end + return true + } + + static func isIdentifierCharacter(_ character: Character) -> Bool { + character == "_" || character.isLetter || character.isNumber + } + + static func isValidIdentifierName(_ value: String) -> Bool { + guard let first = value.first, first == "_" || first.isLetter else { + return false + } + return value.dropFirst().allSatisfy { $0 == "_" || $0.isLetter || $0.isNumber } + } +} diff --git a/Sources/Bash/BashSession.swift b/Sources/Bash/BashSession.swift index 4f94bfa..8514645 100644 --- a/Sources/Bash/BashSession.swift +++ b/Sources/Bash/BashSession.swift @@ -1,16 +1,16 @@ import Foundation public final actor BashSession { - private let filesystemStore: any ShellFilesystem + let filesystemStore: any ShellFilesystem private let options: SessionOptions - private let jobManager: ShellJobManager + let jobManager: ShellJobManager private let permissionAuthorizer: PermissionAuthorizer - private var currentDirectoryStore: String - private var environmentStore: [String: String] + var currentDirectoryStore: String + var environmentStore: [String: String] private var historyStore: [String] private var commandRegistry: [String: AnyBuiltinCommand] - private var shellFunctionStore: [String: String] + var shellFunctionStore: [String: String] public var currentDirectory: String { currentDirectoryStore @@ -290,101 +290,7 @@ public final actor BashSession { return defaults } - private struct CommandSubstitutionOutcome { - var commandLine: String - var stderr: Data - var error: ShellError? - } - - private struct PendingHereDocument { - var delimiter: String - var stripsLeadingTabs: Bool - } - - private struct FunctionDefinitionParseOutcome { - var remaining: String - var error: ShellError? - } - - private struct SimpleForLoop { - enum Kind { - case list(variableName: String, values: [String]) - case cStyle(initializer: String, condition: String, increment: String) - } - - var kind: Kind - var body: String - var trailingAction: TrailingAction - } - - private enum SimpleForLoopParseResult { - case notForLoop - case success(SimpleForLoop) - case failure(ShellError) - } - - private struct IfBranch { - var condition: String - var body: String - } - - private struct SimpleIfBlock { - var branches: [IfBranch] - var elseBody: String? - var trailingAction: TrailingAction - } - - private enum SimpleIfBlockParseResult { - case notIfBlock - case success(SimpleIfBlock) - case failure(ShellError) - } - - private struct SimpleWhileLoop { - var leadingCommands: String? - var condition: String - var isUntil: Bool - var body: String - var trailingAction: TrailingAction - } - - private enum SimpleWhileLoopParseResult { - case notWhileLoop - case success(SimpleWhileLoop) - case failure(ShellError) - } - - private struct SimpleCaseArm { - var patterns: [String] - var body: String - } - - private struct SimpleCaseBlock { - var leadingCommands: String? - var subject: String - var arms: [SimpleCaseArm] - var trailingAction: TrailingAction - } - - private enum SimpleCaseBlockParseResult { - case notCaseBlock - case success(SimpleCaseBlock) - case failure(ShellError) - } - - private enum TrailingAction { - case none - case redirections([Redirection]) - case pipeline(String) - } - - private struct DelimitedKeywordMatch { - var separatorIndex: String.Index - var keywordIndex: String.Index - var afterKeywordIndex: String.Index - } - - private func executeParsedLine( + func executeParsedLine( parsedLine: ParsedLine, stdin: Data, currentDirectory: String, @@ -432,7 +338,7 @@ public final actor BashSession { return execution } - private func executeStandardCommandLine( + func executeStandardCommandLine( _ commandLine: String, stdin: Data ) async -> CommandResult { @@ -458,2639 +364,4 @@ public final actor BashSession { ) } } - - private func expandCommandSubstitutions(in commandLine: String) async -> CommandSubstitutionOutcome { - var output = "" - var stderr = Data() - var quote: QuoteKind = .none - var index = commandLine.startIndex - var pendingHereDocuments: [PendingHereDocument] = [] - - while index < commandLine.endIndex { - let character = commandLine[index] - - if character == "\\", quote != .single { - let next = commandLine.index(after: index) - output.append(character) - if next < commandLine.endIndex { - output.append(commandLine[next]) - index = commandLine.index(after: next) - } else { - index = next - } - continue - } - - if character == "'", quote != .double { - quote = quote == .single ? .none : .single - output.append(character) - index = commandLine.index(after: index) - continue - } - - if character == "\"", quote != .single { - quote = quote == .double ? .none : .double - output.append(character) - index = commandLine.index(after: index) - continue - } - - if quote == .none, - commandLine[index...].hasPrefix("<<"), - let hereDocument = Self.captureHereDocumentDeclaration(in: commandLine, from: index) { - output.append(contentsOf: commandLine[index.. CommandSubstitutionOutcome { - let nested = await expandCommandSubstitutions(in: command) - if let error = nested.error { - return CommandSubstitutionOutcome( - commandLine: "", - stderr: nested.stderr, - error: error - ) - } - - let parsed: ParsedLine - do { - parsed = try ShellParser.parse(nested.commandLine) - } catch let shellError as ShellError { - return CommandSubstitutionOutcome( - commandLine: "", - stderr: nested.stderr, - error: shellError - ) - } catch { - return CommandSubstitutionOutcome( - commandLine: "", - stderr: nested.stderr, - error: .parserError("\(error)") - ) - } - - let execution = await executeParsedLine( - parsedLine: parsed, - stdin: Data(), - currentDirectory: currentDirectoryStore, - environment: environmentStore, - shellFunctions: shellFunctionStore, - jobControl: nil - ) - - var stderr = nested.stderr - stderr.append(execution.result.stderr) - - let replacement = Self.trimmingTrailingNewlines( - from: execution.result.stdoutString - ) - return CommandSubstitutionOutcome( - commandLine: replacement, - stderr: stderr, - error: nil - ) - } - - private func parseAndRegisterFunctionDefinitions( - in commandLine: String - ) -> FunctionDefinitionParseOutcome { - var functionStore = shellFunctionStore - var parsed = Self.parseFunctionDefinitions( - in: commandLine, - functionStore: &functionStore - ) - - if parsed.error == nil, - parsed.remaining == commandLine, - let marker = Self.findDelimitedKeyword( - "function", - in: commandLine, - from: commandLine.startIndex - ) { - let prefix = String(commandLine[.. CommandResult? { - let parsedLoop = parseSimpleForLoop(commandLine) - switch parsedLoop { - case .notForLoop: - return nil - case let .failure(error): - var stderr = prefixedStderr - stderr.append(Data("\(error)\n".utf8)) - return CommandResult(stdout: Data(), stderr: stderr, exitCode: 2) - case let .success(loop): - let parsedBody: ParsedLine - do { - parsedBody = try ShellParser.parse(loop.body) - } catch { - var stderr = prefixedStderr - stderr.append(Data("\(error)\n".utf8)) - return CommandResult(stdout: Data(), stderr: stderr, exitCode: 2) - } - - var combinedOut = Data() - var combinedErr = Data() - var lastExitCode: Int32 = 0 - - switch loop.kind { - case let .list(variableName, values): - for value in values { - environmentStore[variableName] = value - let execution = await executeParsedLine( - parsedLine: parsedBody, - stdin: stdin, - currentDirectory: currentDirectoryStore, - environment: environmentStore, - shellFunctions: shellFunctionStore, - jobControl: jobManager - ) - - currentDirectoryStore = execution.currentDirectory - environmentStore = execution.environment - environmentStore["PWD"] = currentDirectoryStore - - combinedOut.append(execution.result.stdout) - combinedErr.append(execution.result.stderr) - lastExitCode = execution.result.exitCode - } - case let .cStyle(initializer, condition, increment): - if let initializerError = executeCStyleArithmeticStatement(initializer) { - var stderr = prefixedStderr - stderr.append(Data("\(initializerError)\n".utf8)) - return CommandResult(stdout: Data(), stderr: stderr, exitCode: 2) - } - - var iterations = 0 - while true { - iterations += 1 - if iterations > 10_000 { - combinedErr.append(Data("for: exceeded max iterations\n".utf8)) - lastExitCode = 2 - break - } - - let shouldContinue: Bool - if condition.isEmpty { - shouldContinue = true - } else { - let evaluated = ArithmeticEvaluator.evaluate( - condition, - environment: environmentStore - ) ?? 0 - shouldContinue = evaluated != 0 - } - - if !shouldContinue { - break - } - - let execution = await executeParsedLine( - parsedLine: parsedBody, - stdin: stdin, - currentDirectory: currentDirectoryStore, - environment: environmentStore, - shellFunctions: shellFunctionStore, - jobControl: jobManager - ) - - currentDirectoryStore = execution.currentDirectory - environmentStore = execution.environment - environmentStore["PWD"] = currentDirectoryStore - - combinedOut.append(execution.result.stdout) - combinedErr.append(execution.result.stderr) - lastExitCode = execution.result.exitCode - - if let incrementError = executeCStyleArithmeticStatement(increment) { - combinedErr.append(Data("\(incrementError)\n".utf8)) - lastExitCode = 2 - break - } - } - } - - var result = CommandResult( - stdout: combinedOut, - stderr: combinedErr, - exitCode: lastExitCode - ) - await applyTrailingAction(loop.trailingAction, to: &result) - mergePrefixedStderr(prefixedStderr, into: &result) - - return result - } - } - - private func parseSimpleForLoop(_ commandLine: String) -> SimpleForLoopParseResult { - var index = commandLine.startIndex - Self.skipWhitespace(in: commandLine, index: &index) - - guard Self.consumeKeyword( - "for", - in: commandLine, - index: &index - ) else { - return .notForLoop - } - - Self.skipWhitespace(in: commandLine, index: &index) - let loopKind: SimpleForLoop.Kind - - if commandLine[index...].hasPrefix("((") { - guard let cStyle = Self.parseCStyleForHeader(commandLine, index: &index) else { - return .failure(.parserError("for: expected C-style header '((init;cond;inc))'")) - } - - Self.skipWhitespace(in: commandLine, index: &index) - guard let doMarker = Self.findDelimitedKeyword( - "do", - in: commandLine, - from: index - ) else { - return .failure(.parserError("for: expected 'do'")) - } - - index = doMarker.afterKeywordIndex - loopKind = .cStyle( - initializer: cStyle.initializer, - condition: cStyle.condition, - increment: cStyle.increment - ) - } else { - guard let variableName = Self.readIdentifier(in: commandLine, index: &index) else { - return .failure(.parserError("for: expected loop variable")) - } - - Self.skipWhitespace(in: commandLine, index: &index) - guard Self.consumeKeyword("in", in: commandLine, index: &index) else { - return .failure(.parserError("for: expected 'in'")) - } - - Self.skipWhitespace(in: commandLine, index: &index) - guard let valuesMarker = Self.findDelimitedKeyword( - "do", - in: commandLine, - from: index - ) else { - return .failure(.parserError("for: expected 'do'")) - } - - let rawValues = String(commandLine[index.. CommandResult? { - let parsedIf = parseSimpleIfBlock(commandLine) - switch parsedIf { - case .notIfBlock: - return nil - case let .failure(error): - var stderr = prefixedStderr - stderr.append(Data("\(error)\n".utf8)) - return CommandResult(stdout: Data(), stderr: stderr, exitCode: 2) - case let .success(ifBlock): - var combinedOut = Data() - var combinedErr = Data() - var lastExitCode: Int32 = 0 - - var selectedBody: String? - for branch in ifBlock.branches { - let conditionResult = await executeConditionalExpression( - branch.condition, - stdin: stdin - ) - combinedOut.append(conditionResult.stdout) - combinedErr.append(conditionResult.stderr) - lastExitCode = conditionResult.exitCode - - if conditionResult.exitCode == 0 { - selectedBody = branch.body - break - } - } - - if selectedBody == nil { - selectedBody = ifBlock.elseBody - if selectedBody == nil { - lastExitCode = 0 - } - } - - if let selectedBody, !selectedBody.isEmpty { - let bodyResult = await executeStandardCommandLine( - selectedBody, - stdin: stdin - ) - combinedOut.append(bodyResult.stdout) - combinedErr.append(bodyResult.stderr) - lastExitCode = bodyResult.exitCode - } - - var result = CommandResult( - stdout: combinedOut, - stderr: combinedErr, - exitCode: lastExitCode - ) - await applyTrailingAction(ifBlock.trailingAction, to: &result) - mergePrefixedStderr(prefixedStderr, into: &result) - return result - } - } - - private func parseSimpleIfBlock(_ commandLine: String) -> SimpleIfBlockParseResult { - var index = commandLine.startIndex - Self.skipWhitespace(in: commandLine, index: &index) - - guard Self.consumeKeyword("if", in: commandLine, index: &index) else { - return .notIfBlock - } - - Self.skipWhitespace(in: commandLine, index: &index) - guard let thenMarker = Self.findDelimitedKeyword( - "then", - in: commandLine, - from: index - ) else { - return .failure(.parserError("if: expected 'then'")) - } - - let condition = String(commandLine[index.. CommandResult? { - await executeSimpleConditionalLoopIfPresent( - parseSimpleWhileLoop(commandLine), - stdin: stdin, - prefixedStderr: prefixedStderr - ) - } - - private func executeSimpleUntilLoopIfPresent( - commandLine: String, - stdin: Data, - prefixedStderr: Data - ) async -> CommandResult? { - await executeSimpleConditionalLoopIfPresent( - parseSimpleUntilLoop(commandLine), - stdin: stdin, - prefixedStderr: prefixedStderr - ) - } - - private func executeSimpleConditionalLoopIfPresent( - _ parsedLoop: SimpleWhileLoopParseResult, - stdin: Data, - prefixedStderr: Data - ) async -> CommandResult? { - switch parsedLoop { - case .notWhileLoop: - return nil - case let .failure(error): - var stderr = prefixedStderr - stderr.append(Data("\(error)\n".utf8)) - return CommandResult(stdout: Data(), stderr: stderr, exitCode: 2) - case let .success(loop): - let parsedBody: ParsedLine - do { - parsedBody = try ShellParser.parse(loop.body) - } catch { - var stderr = prefixedStderr - stderr.append(Data("\(error)\n".utf8)) - return CommandResult(stdout: Data(), stderr: stderr, exitCode: 2) - } - - var combinedOut = Data() - var combinedErr = Data() - var lastExitCode: Int32 = 0 - var didRunBody = false - - if let leadingCommands = loop.leadingCommands, - !leadingCommands.isEmpty { - let leadingResult = await executeStandardCommandLine( - leadingCommands, - stdin: stdin - ) - combinedOut.append(leadingResult.stdout) - combinedErr.append(leadingResult.stderr) - lastExitCode = leadingResult.exitCode - } - - var iterations = 0 - while true { - iterations += 1 - if iterations > 10_000 { - let loopName = loop.isUntil ? "until" : "while" - combinedErr.append(Data("\(loopName): exceeded max iterations\n".utf8)) - lastExitCode = 2 - break - } - - let conditionResult = await executeConditionalExpression( - loop.condition, - stdin: stdin - ) - combinedOut.append(conditionResult.stdout) - combinedErr.append(conditionResult.stderr) - - let conditionSucceeded = conditionResult.exitCode == 0 - let shouldRunBody = loop.isUntil ? !conditionSucceeded : conditionSucceeded - - if !shouldRunBody { - if !loop.isUntil && conditionResult.exitCode > 1, !didRunBody { - lastExitCode = conditionResult.exitCode - } else if !didRunBody { - lastExitCode = 0 - } - break - } - - let bodyExecution = await executeParsedLine( - parsedLine: parsedBody, - stdin: stdin, - currentDirectory: currentDirectoryStore, - environment: environmentStore, - shellFunctions: shellFunctionStore, - jobControl: jobManager - ) - currentDirectoryStore = bodyExecution.currentDirectory - environmentStore = bodyExecution.environment - environmentStore["PWD"] = currentDirectoryStore - - combinedOut.append(bodyExecution.result.stdout) - combinedErr.append(bodyExecution.result.stderr) - lastExitCode = bodyExecution.result.exitCode - didRunBody = true - } - - var result = CommandResult( - stdout: combinedOut, - stderr: combinedErr, - exitCode: lastExitCode - ) - await applyTrailingAction(loop.trailingAction, to: &result) - mergePrefixedStderr(prefixedStderr, into: &result) - return result - } - } - - private func parseSimpleWhileLoop(_ commandLine: String) -> SimpleWhileLoopParseResult { - parseSimpleConditionalLoop( - commandLine, - keyword: "while", - isUntil: false - ) - } - - private func parseSimpleUntilLoop(_ commandLine: String) -> SimpleWhileLoopParseResult { - parseSimpleConditionalLoop( - commandLine, - keyword: "until", - isUntil: true - ) - } - - private func parseSimpleConditionalLoop( - _ commandLine: String, - keyword: String, - isUntil: Bool - ) -> SimpleWhileLoopParseResult { - var start = commandLine.startIndex - Self.skipWhitespace(in: commandLine, index: &start) - - if commandLine[start...].hasPrefix(keyword) { - return parseConditionalLoopClause( - String(commandLine[start...]), - keyword: keyword, - isUntil: isUntil, - leadingCommands: nil - ) - } - - guard let marker = Self.findDelimitedKeyword( - keyword, - in: commandLine, - from: start - ) else { - return .notWhileLoop - } - - let prefix = String(commandLine[start.. SimpleWhileLoopParseResult { - var index = loopClause.startIndex - Self.skipWhitespace(in: loopClause, index: &index) - guard Self.consumeKeyword(keyword, in: loopClause, index: &index) else { - return .notWhileLoop - } - - Self.skipWhitespace(in: loopClause, index: &index) - guard let doMarker = Self.findDelimitedKeyword( - "do", - in: loopClause, - from: index - ) else { - return .failure(.parserError("\(keyword): expected 'do'")) - } - - let condition = String(loopClause[index.. CommandResult? { - let parsedCase = parseSimpleCaseBlock(commandLine) - switch parsedCase { - case .notCaseBlock: - return nil - case let .failure(error): - var stderr = prefixedStderr - stderr.append(Data("\(error)\n".utf8)) - return CommandResult(stdout: Data(), stderr: stderr, exitCode: 2) - case let .success(caseBlock): - var combinedOut = Data() - var combinedErr = Data() - var lastExitCode: Int32 = 0 - - if let leadingCommands = caseBlock.leadingCommands, - !leadingCommands.isEmpty { - let leadingResult = await executeStandardCommandLine( - leadingCommands, - stdin: stdin - ) - combinedOut.append(leadingResult.stdout) - combinedErr.append(leadingResult.stderr) - lastExitCode = leadingResult.exitCode - } - - let subject = Self.evaluateCaseWord( - caseBlock.subject, - environment: environmentStore - ) - var selectedBody: String? - for arm in caseBlock.arms { - if arm.patterns.contains(where: { Self.casePatternMatches($0, value: subject, environment: environmentStore) }) { - selectedBody = arm.body - break - } - } - - if let selectedBody, !selectedBody.isEmpty { - let bodyResult = await executeStandardCommandLine( - selectedBody, - stdin: stdin - ) - combinedOut.append(bodyResult.stdout) - combinedErr.append(bodyResult.stderr) - lastExitCode = bodyResult.exitCode - } else { - lastExitCode = 0 - } - - var result = CommandResult( - stdout: combinedOut, - stderr: combinedErr, - exitCode: lastExitCode - ) - await applyTrailingAction(caseBlock.trailingAction, to: &result) - mergePrefixedStderr(prefixedStderr, into: &result) - return result - } - } - - private func parseSimpleCaseBlock(_ commandLine: String) -> SimpleCaseBlockParseResult { - var start = commandLine.startIndex - Self.skipWhitespace(in: commandLine, index: &start) - - if commandLine[start...].hasPrefix("case") { - return parseCaseClause( - String(commandLine[start...]), - leadingCommands: nil - ) - } - - guard let marker = Self.findDelimitedKeyword( - "case", - in: commandLine, - from: start - ) else { - return .notCaseBlock - } - - let prefix = String(commandLine[start.. SimpleCaseBlockParseResult { - var index = clause.startIndex - Self.skipWhitespace(in: clause, index: &index) - - guard Self.consumeKeyword("case", in: clause, index: &index) else { - return .notCaseBlock - } - - Self.skipWhitespace(in: clause, index: &index) - guard let inRange = Self.findKeywordTokenRange( - "in", - in: clause, - from: index - ) else { - return .failure(.parserError("case: expected 'in'")) - } - - let subject = String(clause[index.. CommandResult { - if let testResult = await evaluateTestConditionIfPresent(condition) { - return testResult - } - return await executeStandardCommandLine(condition, stdin: stdin) - } - - private func evaluateTestConditionIfPresent(_ condition: String) async -> CommandResult? { - let tokens: [LexToken] - do { - tokens = try ShellLexer.tokenize(condition) - } catch { - return CommandResult( - stdout: Data(), - stderr: Data("\(error)\n".utf8), - exitCode: 2 - ) - } - - var words: [String] = [] - for token in tokens { - guard case let .word(word) = token else { - return nil - } - words.append(Self.expandWord(word, environment: environmentStore)) - } - - guard let first = words.first else { - return nil - } - - var expression = words - if first == "test" { - expression.removeFirst() - } else if first == "[" { - guard expression.last == "]" else { - return CommandResult( - stdout: Data(), - stderr: Data("test: missing ']'\n".utf8), - exitCode: 2 - ) - } - expression.removeFirst() - expression.removeLast() - } else { - return nil - } - - return await evaluateTestExpression(expression) - } - - private func evaluateTestExpression(_ expression: [String]) async -> CommandResult { - if expression.isEmpty { - return CommandResult(stdout: Data(), stderr: Data(), exitCode: 1) - } - - if expression.count == 1 { - let isTrue = !expression[0].isEmpty - return CommandResult( - stdout: Data(), - stderr: Data(), - exitCode: isTrue ? 0 : 1 - ) - } - - if expression.count == 2 { - let op = expression[0] - let value = expression[1] - - switch op { - case "-n": - return CommandResult( - stdout: Data(), - stderr: Data(), - exitCode: value.isEmpty ? 1 : 0 - ) - case "-z": - return CommandResult( - stdout: Data(), - stderr: Data(), - exitCode: value.isEmpty ? 0 : 1 - ) - case "-e", "-f", "-d": - let path = PathUtils.normalize( - path: value, - currentDirectory: currentDirectoryStore - ) - guard await filesystemStore.exists(path: path) else { - return CommandResult(stdout: Data(), stderr: Data(), exitCode: 1) - } - - guard let info = try? await filesystemStore.stat(path: path) else { - return CommandResult(stdout: Data(), stderr: Data(), exitCode: 1) - } - - let passed: Bool - switch op { - case "-e": - passed = true - case "-f": - passed = !info.isDirectory - case "-d": - passed = info.isDirectory - default: - passed = false - } - return CommandResult( - stdout: Data(), - stderr: Data(), - exitCode: passed ? 0 : 1 - ) - default: - return CommandResult( - stdout: Data(), - stderr: Data("test: unsupported expression\n".utf8), - exitCode: 2 - ) - } - } - - if expression.count == 3 { - let lhs = expression[0] - let op = expression[1] - let rhs = expression[2] - - switch op { - case "=", "==": - return CommandResult( - stdout: Data(), - stderr: Data(), - exitCode: lhs == rhs ? 0 : 1 - ) - case "!=": - return CommandResult( - stdout: Data(), - stderr: Data(), - exitCode: lhs != rhs ? 0 : 1 - ) - case "-eq", "-ne", "-lt", "-le", "-gt", "-ge": - guard let leftValue = Int(lhs), let rightValue = Int(rhs) else { - return CommandResult( - stdout: Data(), - stderr: Data("test: integer expression expected\n".utf8), - exitCode: 2 - ) - } - let passed: Bool - switch op { - case "-eq": - passed = leftValue == rightValue - case "-ne": - passed = leftValue != rightValue - case "-lt": - passed = leftValue < rightValue - case "-le": - passed = leftValue <= rightValue - case "-gt": - passed = leftValue > rightValue - case "-ge": - passed = leftValue >= rightValue - default: - passed = false - } - return CommandResult( - stdout: Data(), - stderr: Data(), - exitCode: passed ? 0 : 1 - ) - default: - return CommandResult( - stdout: Data(), - stderr: Data("test: unsupported expression\n".utf8), - exitCode: 2 - ) - } - } - - return CommandResult( - stdout: Data(), - stderr: Data("test: unsupported expression\n".utf8), - exitCode: 2 - ) - } - - private func applyTrailingAction( - _ action: TrailingAction, - to result: inout CommandResult - ) async { - switch action { - case .none: - return - case let .redirections(redirections): - await applyRedirections(redirections, to: &result) - case let .pipeline(pipeline): - do { - let parsed = try ShellParser.parse(pipeline) - let pipelineExecution = await executeParsedLine( - parsedLine: parsed, - stdin: result.stdout, - currentDirectory: currentDirectoryStore, - environment: environmentStore, - shellFunctions: shellFunctionStore, - jobControl: jobManager - ) - currentDirectoryStore = pipelineExecution.currentDirectory - environmentStore = pipelineExecution.environment - environmentStore["PWD"] = currentDirectoryStore - - var mergedStderr = result.stderr - mergedStderr.append(pipelineExecution.result.stderr) - result = CommandResult( - stdout: pipelineExecution.result.stdout, - stderr: mergedStderr, - exitCode: pipelineExecution.result.exitCode - ) - } catch { - result.stdout.removeAll(keepingCapacity: true) - result.stderr.append(Data("\(error)\n".utf8)) - result.exitCode = 2 - } - } - } - - private func mergePrefixedStderr(_ prefixedStderr: Data, into result: inout CommandResult) { - guard !prefixedStderr.isEmpty else { - return - } - - var merged = prefixedStderr - merged.append(result.stderr) - result.stderr = merged - } - - private func applyRedirections( - _ redirections: [Redirection], - to result: inout CommandResult - ) async { - for redirection in redirections { - switch redirection.type { - case .stdin: - continue - case .stderrToStdout: - result.stdout.append(result.stderr) - result.stderr.removeAll(keepingCapacity: true) - case .stdoutTruncate, .stdoutAppend: - guard let targetWord = redirection.target else { continue } - let target = Self.expandWord( - targetWord, - environment: environmentStore - ) - let path = PathUtils.normalize( - path: target, - currentDirectory: currentDirectoryStore - ) - do { - try await filesystemStore.writeFile( - path: path, - data: result.stdout, - append: redirection.type == .stdoutAppend - ) - result.stdout.removeAll(keepingCapacity: true) - } catch { - result.stderr.append(Data("\(target): \(error)\n".utf8)) - result.exitCode = 1 - } - case .stderrTruncate, .stderrAppend: - guard let targetWord = redirection.target else { continue } - let target = Self.expandWord( - targetWord, - environment: environmentStore - ) - let path = PathUtils.normalize( - path: target, - currentDirectory: currentDirectoryStore - ) - do { - try await filesystemStore.writeFile( - path: path, - data: result.stderr, - append: redirection.type == .stderrAppend - ) - result.stderr.removeAll(keepingCapacity: true) - } catch { - result.stderr.append(Data("\(target): \(error)\n".utf8)) - result.exitCode = 1 - } - case .stdoutAndErrTruncate, .stdoutAndErrAppend: - guard let targetWord = redirection.target else { continue } - let target = Self.expandWord( - targetWord, - environment: environmentStore - ) - let path = PathUtils.normalize( - path: target, - currentDirectory: currentDirectoryStore - ) - var combined = Data() - combined.append(result.stdout) - combined.append(result.stderr) - do { - try await filesystemStore.writeFile( - path: path, - data: combined, - append: redirection.type == .stdoutAndErrAppend - ) - result.stdout.removeAll(keepingCapacity: true) - result.stderr.removeAll(keepingCapacity: true) - } catch { - result.stderr.append(Data("\(target): \(error)\n".utf8)) - result.exitCode = 1 - } - } - } - } - - private static func captureCommandSubstitution( - in commandLine: String, - from dollarIndex: String.Index - ) throws -> (content: String, endIndex: String.Index) { - let openIndex = commandLine.index(after: dollarIndex) - var index = commandLine.index(after: openIndex) - let contentStart = index - var depth = 1 - var quote: QuoteKind = .none - - while index < commandLine.endIndex { - let character = commandLine[index] - - if character == "\\", quote != .single { - let next = commandLine.index(after: index) - if next < commandLine.endIndex { - index = commandLine.index(after: next) - } else { - index = next - } - continue - } - - if character == "'", quote != .double { - quote = quote == .single ? .none : .single - index = commandLine.index(after: index) - continue - } - - if character == "\"", quote != .single { - quote = quote == .double ? .none : .double - index = commandLine.index(after: index) - continue - } - - if quote == .none { - if character == "(" { - depth += 1 - } else if character == ")" { - depth -= 1 - if depth == 0 { - let content = String(commandLine[contentStart.. (raw: String, endIndex: String.Index)? { - let open = commandLine.index(after: dollarIndex) - guard open < commandLine.endIndex, commandLine[open] == "(" else { - return nil - } - - let secondOpen = commandLine.index(after: open) - guard secondOpen < commandLine.endIndex, commandLine[secondOpen] == "(" else { - return nil - } - - var depth = 1 - var cursor = commandLine.index(after: secondOpen) - - while cursor < commandLine.endIndex { - if commandLine[cursor] == "(" { - let next = commandLine.index(after: cursor) - if next < commandLine.endIndex, commandLine[next] == "(" { - depth += 1 - cursor = commandLine.index(after: next) - continue - } - } else if commandLine[cursor] == ")" { - let next = commandLine.index(after: cursor) - if next < commandLine.endIndex, commandLine[next] == ")" { - depth -= 1 - if depth == 0 { - let end = commandLine.index(after: next) - return (raw: String(commandLine[dollarIndex.. (delimiter: String, stripsLeadingTabs: Bool, endIndex: String.Index)? { - let stripsLeadingTabs: Bool - let indexOffset: Int - - if commandLine[operatorIndex...].hasPrefix("<<-") { - stripsLeadingTabs = true - indexOffset = 3 - } else { - stripsLeadingTabs = false - indexOffset = 2 - } - - var index = commandLine.index(operatorIndex, offsetBy: indexOffset) - - while index < commandLine.endIndex, - commandLine[index].isWhitespace, - commandLine[index] != "\n" { - index = commandLine.index(after: index) - } - - guard index < commandLine.endIndex, commandLine[index] != "\n" else { - return nil - } - - var delimiter = "" - var quote: QuoteKind = .none - var consumedAny = false - - while index < commandLine.endIndex { - let character = commandLine[index] - - if quote == .none, character.isWhitespace { - break - } - - if character == "'", quote != .double { - quote = quote == .single ? .none : .single - consumedAny = true - index = commandLine.index(after: index) - continue - } - - if character == "\"", quote != .single { - quote = quote == .double ? .none : .double - consumedAny = true - index = commandLine.index(after: index) - continue - } - - if character == "\\", quote != .single { - let next = commandLine.index(after: index) - if next < commandLine.endIndex { - delimiter.append(commandLine[next]) - index = commandLine.index(after: next) - } else { - delimiter.append(character) - index = next - } - consumedAny = true - continue - } - - delimiter.append(character) - consumedAny = true - index = commandLine.index(after: index) - } - - guard consumedAny, quote == .none else { - return nil - } - - return (delimiter: delimiter, stripsLeadingTabs: stripsLeadingTabs, endIndex: index) - } - - private static func captureHereDocumentBodiesVerbatim( - in commandLine: String, - from startIndex: String.Index, - hereDocuments: [PendingHereDocument] - ) throws -> (raw: String, endIndex: String.Index) { - var raw = "" - var index = startIndex - - for hereDocument in hereDocuments { - var matched = false - - while index < commandLine.endIndex { - let lineStart = index - while index < commandLine.endIndex, commandLine[index] != "\n" { - index = commandLine.index(after: index) - } - - let line = String(commandLine[lineStart.. String { - String(line.drop { $0 == "\t" }) - } - - private static func parseFunctionDefinitions( - in commandLine: String, - functionStore: inout [String: String] - ) -> FunctionDefinitionParseOutcome { - var index = commandLine.startIndex - Self.skipWhitespace(in: commandLine, index: &index) - var parsedAny = false - - while index < commandLine.endIndex { - let definitionStart = index - - let hasFunctionKeyword: Bool - let functionName: String - - if Self.consumeKeyword("function", in: commandLine, index: &index) { - hasFunctionKeyword = true - Self.skipWhitespace(in: commandLine, index: &index) - guard let parsedName = Self.readIdentifier(in: commandLine, index: &index) else { - return FunctionDefinitionParseOutcome( - remaining: commandLine, - error: .parserError("function: expected function name") - ) - } - functionName = parsedName - } else { - hasFunctionKeyword = false - guard let parsedName = Self.readIdentifier(in: commandLine, index: &index) else { - let remaining = String(commandLine[definitionStart...]) - .trimmingCharacters(in: .whitespacesAndNewlines) - return FunctionDefinitionParseOutcome( - remaining: parsedAny ? remaining : commandLine, - error: nil - ) - } - functionName = parsedName - } - - Self.skipWhitespace(in: commandLine, index: &index) - var hasParenthesizedSignature = false - if Self.consumeLiteral("(", in: commandLine, index: &index) { - hasParenthesizedSignature = true - Self.skipWhitespace(in: commandLine, index: &index) - guard Self.consumeLiteral(")", in: commandLine, index: &index) else { - if hasFunctionKeyword { - return FunctionDefinitionParseOutcome( - remaining: commandLine, - error: .parserError("function \(functionName): expected ')'")) - } - - let remaining = String(commandLine[definitionStart...]) - .trimmingCharacters(in: .whitespacesAndNewlines) - return FunctionDefinitionParseOutcome( - remaining: parsedAny ? remaining : commandLine, - error: nil - ) - } - Self.skipWhitespace(in: commandLine, index: &index) - } else if !hasFunctionKeyword { - let remaining = String(commandLine[definitionStart...]) - .trimmingCharacters(in: .whitespacesAndNewlines) - return FunctionDefinitionParseOutcome( - remaining: parsedAny ? remaining : commandLine, - error: nil - ) - } - - guard index < commandLine.endIndex, commandLine[index] == "{" else { - if hasFunctionKeyword || hasParenthesizedSignature { - return FunctionDefinitionParseOutcome( - remaining: commandLine, - error: .parserError("function \(functionName): expected '{'")) - } - - let remaining = String(commandLine[definitionStart...]) - .trimmingCharacters(in: .whitespacesAndNewlines) - return FunctionDefinitionParseOutcome( - remaining: parsedAny ? remaining : commandLine, - error: nil - ) - } - - do { - let braceCapture = try captureBalancedBraces( - in: commandLine, - from: index - ) - let body = String(commandLine[braceCapture.contentRange]) - .trimmingCharacters(in: .whitespacesAndNewlines) - functionStore[functionName] = body - parsedAny = true - index = braceCapture.endIndex - } catch let shellError as ShellError { - return FunctionDefinitionParseOutcome( - remaining: commandLine, - error: shellError - ) - } catch { - return FunctionDefinitionParseOutcome( - remaining: commandLine, - error: .parserError("\(error)") - ) - } - - let boundary = index - Self.skipWhitespace(in: commandLine, index: &index) - if index == commandLine.endIndex { - return FunctionDefinitionParseOutcome( - remaining: "", - error: nil - ) - } - - if commandLine[index] == ";" { - index = commandLine.index(after: index) - Self.skipWhitespace(in: commandLine, index: &index) - if index == commandLine.endIndex { - return FunctionDefinitionParseOutcome( - remaining: "", - error: nil - ) - } - continue - } - - let remaining = String(commandLine[boundary...]) - .trimmingCharacters(in: .whitespacesAndNewlines) - return FunctionDefinitionParseOutcome( - remaining: remaining, - error: nil - ) - } - - return FunctionDefinitionParseOutcome( - remaining: parsedAny ? "" : commandLine, - error: nil - ) - } - - private static func captureBalancedBraces( - in commandLine: String, - from openBraceIndex: String.Index - ) throws -> (contentRange: Range, endIndex: String.Index) { - var index = commandLine.index(after: openBraceIndex) - let contentStart = index - var depth = 1 - var quote: QuoteKind = .none - - while index < commandLine.endIndex { - let character = commandLine[index] - - if character == "\\", quote != .single { - let next = commandLine.index(after: index) - if next < commandLine.endIndex { - index = commandLine.index(after: next) - } else { - index = next - } - continue - } - - if character == "'", quote != .double { - quote = quote == .single ? .none : .single - index = commandLine.index(after: index) - continue - } - - if character == "\"", quote != .single { - quote = quote == .double ? .none : .double - index = commandLine.index(after: index) - continue - } - - if quote == .none { - if character == "{" { - depth += 1 - } else if character == "}" { - depth -= 1 - if depth == 0 { - return ( - contentRange: contentStart.. DelimitedKeywordMatch? { - var quote: QuoteKind = .none - var index = startIndex - let endIndex = end ?? commandLine.endIndex - - while index < endIndex { - let character = commandLine[index] - - if character == "\\", quote != .single { - let next = commandLine.index(after: index) - if next < endIndex { - index = commandLine.index(after: next) - } else { - index = next - } - continue - } - - if character == "'", quote != .double { - quote = quote == .single ? .none : .single - index = commandLine.index(after: index) - continue - } - - if character == "\"", quote != .single { - quote = quote == .double ? .none : .double - index = commandLine.index(after: index) - continue - } - - if quote == .none, character == ";" || character == "\n" { - var cursor = commandLine.index(after: index) - while cursor < endIndex, commandLine[cursor].isWhitespace { - cursor = commandLine.index(after: cursor) - } - guard cursor < endIndex else { - return nil - } - - guard commandLine[cursor...].hasPrefix(keyword) else { - index = commandLine.index(after: index) - continue - } - - let afterKeyword = commandLine.index( - cursor, - offsetBy: keyword.count - ) - if afterKeyword < commandLine.endIndex, - Self.isIdentifierCharacter(commandLine[afterKeyword]) { - index = commandLine.index(after: index) - continue - } - - return DelimitedKeywordMatch( - separatorIndex: index, - keywordIndex: cursor, - afterKeywordIndex: afterKeyword - ) - } - - index = commandLine.index(after: index) - } - - return nil - } - - private static func findFirstDelimitedKeyword( - _ keywords: [String], - in commandLine: String, - from startIndex: String.Index, - end: String.Index? = nil - ) -> (keyword: String, match: DelimitedKeywordMatch)? { - var best: (keyword: String, match: DelimitedKeywordMatch)? - for keyword in keywords { - guard let match = findDelimitedKeyword( - keyword, - in: commandLine, - from: startIndex, - end: end - ) else { - continue - } - - if let currentBest = best { - if match.separatorIndex < currentBest.match.separatorIndex { - best = (keyword, match) - } - } else { - best = (keyword, match) - } - } - return best - } - - private static func findKeywordTokenRange( - _ keyword: String, - in value: String, - from start: String.Index - ) -> Range? { - var quote: QuoteKind = .none - var index = start - - while index < value.endIndex { - let character = value[index] - - if character == "\\", quote != .single { - let next = value.index(after: index) - if next < value.endIndex { - index = value.index(after: next) - } else { - index = next - } - continue - } - - if character == "'", quote != .double { - quote = quote == .single ? .none : .single - index = value.index(after: index) - continue - } - - if character == "\"", quote != .single { - quote = quote == .double ? .none : .double - index = value.index(after: index) - continue - } - - if quote == .none, value[index...].hasPrefix(keyword) { - let afterKeyword = value.index(index, offsetBy: keyword.count) - let beforeBoundary: Bool - if index == value.startIndex { - beforeBoundary = true - } else { - let previous = value[value.index(before: index)] - beforeBoundary = isKeywordBoundaryCharacter(previous) - } - - let afterBoundary: Bool - if afterKeyword == value.endIndex { - afterBoundary = true - } else { - afterBoundary = isKeywordBoundaryCharacter(value[afterKeyword]) - } - - if beforeBoundary, afterBoundary { - return index.. Bool { - character.isWhitespace || character == ";" || character == "(" || character == ")" - } - - private static func parseLoopValues( - _ rawValues: String, - environment: [String: String] - ) throws -> [String] { - let tokens = try ShellLexer.tokenize(rawValues) - var values: [String] = [] - for token in tokens { - guard case let .word(word) = token else { - throw ShellError.parserError("for: unsupported loop value syntax") - } - values.append(expandWord(word, environment: environment)) - } - return values - } - - private static func parseCStyleForHeader( - _ commandLine: String, - index: inout String.Index - ) -> (initializer: String, condition: String, increment: String)? { - guard Self.consumeLiteral("(", in: commandLine, index: &index), - Self.consumeLiteral("(", in: commandLine, index: &index) else { - return nil - } - - let secondOpen = commandLine.index(before: index) - guard let capture = captureBalancedDoubleParentheses( - in: commandLine, - secondOpen: secondOpen - ) else { - return nil - } - - let components = splitCStyleForComponents(capture.content) - guard components.count == 3 else { - return nil - } - - index = capture.endIndex - return ( - initializer: components[0].trimmingCharacters(in: .whitespacesAndNewlines), - condition: components[1].trimmingCharacters(in: .whitespacesAndNewlines), - increment: components[2].trimmingCharacters(in: .whitespacesAndNewlines) - ) - } - - private static func captureBalancedDoubleParentheses( - in string: String, - secondOpen: String.Index - ) -> (content: String, endIndex: String.Index)? { - var depth = 1 - var cursor = string.index(after: secondOpen) - let contentStart = cursor - - while cursor < string.endIndex { - if string[cursor] == "(" { - let next = string.index(after: cursor) - if next < string.endIndex, string[next] == "(" { - depth += 1 - cursor = string.index(after: next) - continue - } - } else if string[cursor] == ")" { - let next = string.index(after: cursor) - if next < string.endIndex, string[next] == ")" { - depth -= 1 - if depth == 0 { - return ( - content: String(string[contentStart.. [String] { - var components: [String] = [] - var current = "" - var depth = 0 - var quote: QuoteKind = .none - var index = value.startIndex - - while index < value.endIndex { - let character = value[index] - - if character == "\\", quote != .single { - current.append(character) - let next = value.index(after: index) - if next < value.endIndex { - current.append(value[next]) - index = value.index(after: next) - } else { - index = next - } - continue - } - - if character == "'", quote != .double { - quote = quote == .single ? .none : .single - current.append(character) - index = value.index(after: index) - continue - } - - if character == "\"", quote != .single { - quote = quote == .double ? .none : .double - current.append(character) - index = value.index(after: index) - continue - } - - if quote == .none { - if character == "(" { - depth += 1 - } else if character == ")" { - depth = max(0, depth - 1) - } else if character == ";", depth == 0 { - components.append(current) - current = "" - index = value.index(after: index) - continue - } - } - - current.append(character) - index = value.index(after: index) - } - - components.append(current) - return components - } - - private func executeCStyleArithmeticStatement(_ statement: String) -> ShellError? { - let trimmed = statement.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return nil - } - - if trimmed.hasSuffix("++") || trimmed.hasSuffix("--") { - let suffixLength = 2 - let end = trimmed.index(trimmed.endIndex, offsetBy: -suffixLength) - let name = String(trimmed[.. [SimpleCaseArm] { - let trimmed = rawArms.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return [] - } - - var arms: [SimpleCaseArm] = [] - var index = trimmed.startIndex - - while index < trimmed.endIndex { - while index < trimmed.endIndex && - (trimmed[index].isWhitespace || trimmed[index] == ";") { - index = trimmed.index(after: index) - } - guard index < trimmed.endIndex else { - break - } - - guard let closeParen = findUnquotedCharacter( - ")", - in: trimmed, - from: index - ) else { - throw ShellError.parserError("case: expected ')' in pattern arm") - } - - let patternChunk = String(trimmed[index.. String { - do { - let tokens = try ShellLexer.tokenize(raw) - let words = tokens.compactMap { token -> String? in - guard case let .word(word) = token else { - return nil - } - return expandWord(word, environment: environment) - } - if words.isEmpty { - return raw.trimmingCharacters(in: .whitespacesAndNewlines) - } - return words.joined(separator: " ") - } catch { - return expandVariables( - in: raw.trimmingCharacters(in: .whitespacesAndNewlines), - environment: environment - ) - } - } - - private static func casePatternMatches( - _ rawPattern: String, - value: String, - environment: [String: String] - ) -> Bool { - let expanded = evaluateCaseWord(rawPattern, environment: environment) - guard let regex = try? NSRegularExpression(pattern: PathUtils.globToRegex(expanded)) else { - return expanded == value - } - - let range = NSRange(value.startIndex.. [String] { - var parts: [String] = [] - var current = "" - var quote: QuoteKind = .none - var index = value.startIndex - - while index < value.endIndex { - let character = value[index] - - if character == "\\", quote != .single { - current.append(character) - let next = value.index(after: index) - if next < value.endIndex { - current.append(value[next]) - index = value.index(after: next) - } else { - index = next - } - continue - } - - if character == "'", quote != .double { - quote = quote == .single ? .none : .single - current.append(character) - index = value.index(after: index) - continue - } - - if character == "\"", quote != .single { - quote = quote == .double ? .none : .double - current.append(character) - index = value.index(after: index) - continue - } - - if quote == .none, character == "|" { - parts.append(current) - current = "" - index = value.index(after: index) - continue - } - - current.append(character) - index = value.index(after: index) - } - - parts.append(current) - return parts - } - - private static func findUnquotedCharacter( - _ target: Character, - in value: String, - from start: String.Index - ) -> String.Index? { - var quote: QuoteKind = .none - var index = start - - while index < value.endIndex { - let character = value[index] - - if character == "\\", quote != .single { - let next = value.index(after: index) - if next < value.endIndex { - index = value.index(after: next) - } else { - index = next - } - continue - } - - if character == "'", quote != .double { - quote = quote == .single ? .none : .single - index = value.index(after: index) - continue - } - - if character == "\"", quote != .single { - quote = quote == .double ? .none : .double - index = value.index(after: index) - continue - } - - if quote == .none, character == target { - return index - } - - index = value.index(after: index) - } - - return nil - } - - private static func findUnquotedDoubleSemicolon( - in value: String, - from start: String.Index - ) -> Range? { - var quote: QuoteKind = .none - var index = start - - while index < value.endIndex { - let character = value[index] - - if character == "\\", quote != .single { - let next = value.index(after: index) - if next < value.endIndex { - index = value.index(after: next) - } else { - index = next - } - continue - } - - if character == "'", quote != .double { - quote = quote == .single ? .none : .single - index = value.index(after: index) - continue - } - - if character == "\"", quote != .single { - quote = quote == .double ? .none : .double - index = value.index(after: index) - continue - } - - if quote == .none, character == ";" { - let next = value.index(after: index) - if next < value.endIndex, value[next] == ";" { - return index.. Result { - let trimmed = trailing.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return .success(.none) - } - - if trimmed.hasPrefix("|") { - let tail = String(trimmed.dropFirst()) - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !tail.isEmpty else { - return .failure(.parserError("\(context): expected command after '|'")) - } - return .success(.pipeline(tail)) - } - - switch parseRedirections(from: trimmed, context: context) { - case let .success(redirections): - return .success(.redirections(redirections)) - case let .failure(error): - return .failure(error) - } - } - - private static func parseRedirections( - from trailing: String, - context: String - ) -> Result<[Redirection], ShellError> { - let trimmed = trailing.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return .success([]) - } - - do { - let parsed = try ShellParser.parse("true \(trimmed)") - guard parsed.segments.count == 1, - let segment = parsed.segments.first, - segment.connector == nil, - segment.pipeline.count == 1, - !segment.runInBackground, - segment.pipeline[0].words.count == 1, - segment.pipeline[0].words[0].rawValue == "true" else { - return .failure( - .parserError("\(context): unsupported trailing syntax") - ) - } - return .success(segment.pipeline[0].redirections) - } catch let shellError as ShellError { - return .failure(shellError) - } catch { - return .failure(.parserError("\(error)")) - } - } - - private static func trimmingTrailingNewlines(from value: String) -> String { - var output = value - while output.hasSuffix("\n") { - output.removeLast() - } - return output - } - - private static func expandWord( - _ word: ShellWord, - environment: [String: String] - ) -> String { - var output = "" - for part in word.parts { - switch part.quote { - case .single: - output.append(part.text) - case .none, .double: - output.append(expandVariables(in: part.text, environment: environment)) - } - } - return output - } - - private static func expandVariables( - in string: String, - environment: [String: String] - ) -> String { - var result = "" - var index = string.startIndex - - func readIdentifier(startingAt start: String.Index) -> (String, String.Index) { - var cursor = start - var value = "" - while cursor < string.endIndex { - let character = string[cursor] - if character.isLetter || character.isNumber || character == "_" { - value.append(character) - cursor = string.index(after: cursor) - } else { - break - } - } - return (value, cursor) - } - - while index < string.endIndex { - let character = string[index] - guard character == "$" else { - result.append(character) - index = string.index(after: index) - continue - } - - let next = string.index(after: index) - guard next < string.endIndex else { - result.append("$") - break - } - - if string[next] == "!" { - result += environment["!"] ?? "" - index = string.index(after: next) - continue - } - - if string[next] == "@" || string[next] == "*" || string[next] == "#" { - result += environment[String(string[next])] ?? "" - index = string.index(after: next) - continue - } - - if string[next] == "{" { - guard let close = string[next...].firstIndex(of: "}") else { - result.append("$") - index = next - continue - } - - let contentStart = string.index(after: next) - let content = String(string[contentStart.. String? { - guard index < commandLine.endIndex else { - return nil - } - - let first = commandLine[index] - guard first == "_" || first.isLetter else { - return nil - } - - var value = String(first) - index = commandLine.index(after: index) - while index < commandLine.endIndex, - isIdentifierCharacter(commandLine[index]) { - value.append(commandLine[index]) - index = commandLine.index(after: index) - } - return value - } - - private static func consumeLiteral( - _ literal: Character, - in commandLine: String, - index: inout String.Index - ) -> Bool { - guard index < commandLine.endIndex, - commandLine[index] == literal else { - return false - } - index = commandLine.index(after: index) - return true - } - - private static func consumeKeyword( - _ keyword: String, - in commandLine: String, - index: inout String.Index - ) -> Bool { - guard commandLine[index...].hasPrefix(keyword) else { - return false - } - - let end = commandLine.index(index, offsetBy: keyword.count) - if end < commandLine.endIndex, - isIdentifierCharacter(commandLine[end]) { - return false - } - - index = end - return true - } - - private static func isIdentifierCharacter(_ character: Character) -> Bool { - character == "_" || character.isLetter || character.isNumber - } - - private static func isValidIdentifierName(_ value: String) -> Bool { - guard let first = value.first, first == "_" || first.isLetter else { - return false - } - return value.dropFirst().allSatisfy { $0 == "_" || $0.isLetter || $0.isNumber } - } - } From 13eb4782b974799e2e5936628793b70974d465fb Mon Sep 17 00:00:00 2001 From: Zac White Date: Thu, 19 Mar 2026 23:00:33 -0700 Subject: [PATCH 05/14] Updated network security model. Added execution limits. More filesystem options. --- README.md | 43 ++- Sources/Bash/BashSession+ControlFlow.swift | 31 +- Sources/Bash/BashSession+Expansion.swift | 52 ++- Sources/Bash/BashSession.swift | 44 ++- Sources/Bash/Commands/BuiltinCommand.swift | 23 +- Sources/Bash/Core/ShellExecutor.swift | 152 +++++++- Sources/Bash/FS/MountableFilesystem.swift | 359 ++++++++++++++++++ Sources/Bash/FS/OverlayFilesystem.swift | 172 +++++++++ Sources/Bash/Support/ExecutionControl.swift | 99 +++++ Sources/Bash/Support/Permissions.swift | 103 ++++- Sources/Bash/Support/Types.swift | 34 +- Tests/BashGitTests/GitCommandTests.swift | 10 +- .../CPythonRuntimeIntegrationTests.swift | 10 +- .../SecretsCommandTests.swift | 5 +- Tests/BashSecretsTests/TestSupport.swift | 4 +- Tests/BashTests/FilesystemOptionsTests.swift | 76 ++++ Tests/BashTests/SessionIntegrationTests.swift | 82 +++- Tests/BashTests/TestSupport.swift | 3 +- docs/command-parity-gaps.md | 2 +- 19 files changed, 1233 insertions(+), 71 deletions(-) create mode 100644 Sources/Bash/FS/MountableFilesystem.swift create mode 100644 Sources/Bash/FS/OverlayFilesystem.swift create mode 100644 Sources/Bash/Support/ExecutionControl.swift diff --git a/README.md b/README.md index dcb9aee..810fe60 100644 --- a/README.md +++ b/README.md @@ -224,10 +224,27 @@ public struct RunOptions { public var environment: [String: String] public var replaceEnvironment: Bool public var currentDirectory: String? + public var executionLimits: ExecutionLimits? + public var cancellationCheck: (@Sendable () -> Bool)? } ``` -Use `RunOptions` when you want a Cloudflare-style per-execution override without changing the session's persisted shell state. Filesystem mutations still persist; environment, working-directory, and function changes from that run do not. +Use `RunOptions` when you want a Cloudflare-style per-execution override without changing the session's persisted shell state. Filesystem mutations still persist; environment, working-directory, and function changes from that run do not. You can also tighten execution budgets or provide a host cancellation probe for a single run. + +### `ExecutionLimits` + +```swift +public struct ExecutionLimits { + public static let `default`: ExecutionLimits + + public var maxCommandCount: Int + public var maxFunctionDepth: Int + public var maxLoopIterations: Int + public var maxCommandSubstitutionDepth: Int +} +``` + +Each `run` executes under an `ExecutionLimits` budget. Exceeding a limit stops execution with exit code `2`. If `cancellationCheck` returns `true`, or the surrounding task is cancelled, execution stops with exit code `130`. ### `PermissionRequest` and `PermissionDecision` @@ -257,15 +274,17 @@ public enum PermissionDecision { ```swift public struct NetworkPolicy { + public static let disabled: NetworkPolicy public static let unrestricted: NetworkPolicy + public var allowsHTTPRequests: Bool public var allowedHosts: [String] public var allowedURLPrefixes: [String] public var denyPrivateRanges: Bool } ``` -Use `allowedHosts` for host-based allowlists that should also apply to `git` remotes and Python socket connections. Use `allowedURLPrefixes` for URL-aware tools like `curl`/`wget`. When both are empty, the built-in policy is unrestricted. +Outbound HTTP(S) is disabled by default. Use `.unrestricted` or set `allowsHTTPRequests: true` to opt in. `allowedHosts` fits host-level allowlisting that should also apply to `git` remotes and Python socket connections. `allowedURLPrefixes` is stricter and is matched with exact scheme/host/port plus path-boundary validation for URL-aware tools like `curl` and `wget`. When an allowlist is present, a request must match the host list or the URL-prefix list before any private-range DNS checks run. ### `SessionOptions` @@ -277,6 +296,7 @@ public struct SessionOptions { public var enableGlobbing: Bool public var maxHistory: Int public var networkPolicy: NetworkPolicy + public var executionLimits: ExecutionLimits public var permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? public var secretPolicy: SecretHandlingPolicy public var secretResolver: (any SecretReferenceResolving)? @@ -290,19 +310,21 @@ Defaults: - `initialEnvironment`: `[:]` - `enableGlobbing`: `true` - `maxHistory`: `1000` -- `networkPolicy`: `NetworkPolicy.unrestricted` +- `networkPolicy`: `NetworkPolicy.disabled` +- `executionLimits`: `ExecutionLimits.default` - `permissionHandler`: `nil` - `secretPolicy`: `.off` - `secretResolver`: `nil` - `secretOutputRedactor`: `DefaultSecretOutputRedactor()` -Use `networkPolicy` for built-in outbound rules such as private-range blocking and allowlists. Use `permissionHandler` when the host app or agent needs explicit control over outbound permissions after the built-in policy passes. Returning `.allow` grants the current request once, `.allowForSession` caches an exact-match request for the life of that `BashSession`, and `.deny(message:)` blocks it with a user-visible error. If you want broader or persistent memory across sessions, keep that policy in the host and decide what to return from the callback. +Use `networkPolicy` for built-in outbound rules such as default-off HTTP(S), private-range blocking, and allowlists. Use `executionLimits` to bound shell work at the session level. Use `permissionHandler` when the host app or agent needs explicit control over outbound permissions after the built-in policy passes. Returning `.allow` grants the current request once, `.allowForSession` caches an exact-match request for the life of that `BashSession`, and `.deny(message:)` blocks it with a user-visible error. If you want broader or persistent memory across sessions, keep that policy in the host and decide what to return from the callback. Example built-in policy plus callback: ```swift let options = SessionOptions( networkPolicy: NetworkPolicy( + allowsHTTPRequests: true, allowedHosts: ["api.example.com"], allowedURLPrefixes: ["https://api.example.com/v1/"], denyPrivateRanges: true @@ -322,6 +344,8 @@ let options = SessionOptions( Available filesystem implementations: - `ReadWriteFilesystem`: root-jail wrapper over real disk I/O. - `InMemoryFilesystem`: fully in-memory filesystem with no disk writes. +- `OverlayFilesystem`: snapshots an on-disk root into an in-memory overlay for the session; later writes stay in memory. +- `MountableFilesystem`: composes multiple filesystems under virtual mount points like `/workspace` and `/docs`. - `SandboxFilesystem`: resolves app container-style roots (`documents`, `caches`, `temporary`, app group, custom URL). - `SecurityScopedFilesystem`: URL/bookmark-backed filesystem for security-scoped access. @@ -347,7 +371,8 @@ Execution pipeline: Current hardening layers include: - Root-jail filesystem implementations plus null-byte path rejection. -- Optional `NetworkPolicy` rules (`denyPrivateRanges`, host allowlists, URL-prefix allowlists) and the host `permissionHandler`. +- Optional `NetworkPolicy` rules with default-off HTTP(S), `denyPrivateRanges`, host allowlists, URL-prefix allowlists, and the host `permissionHandler`. +- Built-in execution budgets for command count, loop iterations, function depth, and command substitution depth, plus host-driven cancellation. - Strict `BashPython` shims that block process/FFI escape APIs like `subprocess`, `ctypes`, and `os.system`. - Secret-reference resolution/redaction policies that keep opaque references in model-visible flows by default. @@ -382,6 +407,8 @@ Security-sensitive embeddings should still assume the host app owns the real tru Built-in filesystem options: - `ReadWriteFilesystem` (default): rooted at your `rootDirectory`; reads/writes hit disk in that sandboxed root. - `InMemoryFilesystem`: virtual tree stored in memory; no file mutations are written to disk. +- `OverlayFilesystem`: imports an on-disk root into memory at session start; later writes stay in memory and do not modify the host root. +- `MountableFilesystem`: routes different virtual path prefixes to different filesystem backends. - `SandboxFilesystem`: root resolved from container locations, then backed by `ReadWriteFilesystem`. - `SecurityScopedFilesystem`: root resolved from security-scoped URL or bookmark, then backed by `ReadWriteFilesystem`. @@ -399,7 +426,7 @@ let inMemory = SessionOptions(filesystem: InMemoryFilesystem()) let session = try await BashSession(options: inMemory) ``` -`BashSession.init(options:)` works with filesystems that can self-configure for a session (`SessionConfigurableFilesystem`), such as `InMemoryFilesystem`, `SandboxFilesystem`, and `SecurityScopedFilesystem`. +`BashSession.init(options:)` works with filesystems that can self-configure for a session (`SessionConfigurableFilesystem`), such as `InMemoryFilesystem`, `OverlayFilesystem`, `MountableFilesystem`, `SandboxFilesystem`, and `SecurityScopedFilesystem`. You can provide a custom filesystem by implementing `ShellFilesystem`. @@ -409,6 +436,8 @@ You can provide a custom filesystem by implementing `ShellFilesystem`. | --- | --- | --- | --- | --- | | `ReadWriteFilesystem` | supported | supported | supported | supported | | `InMemoryFilesystem` | supported | supported | supported | supported | +| `OverlayFilesystem` | supported | supported | supported | supported | +| `MountableFilesystem` | supported | supported | supported | supported | | `SandboxFilesystem` | supported (where root resolves) | supported (where root resolves) | supported (where root resolves) | supported (where root resolves) | | `SecurityScopedFilesystem` | supported | supported | supported | compiles; throws `ShellError.unsupported` when configured | @@ -550,7 +579,7 @@ All implemented commands support `--help`. | `html-to-markdown` | `-b/--bullet `, `-c/--code `, `-r/--hr `, `--heading-style `; input from file or stdin; strips `script/style/footer` blocks; supports nested lists and Markdown table rendering | When `SessionOptions.secretPolicy` is `.resolveAndRedact` or `.strict`, `curl` resolves `secretref:v1:...` tokens in headers/body arguments and output redaction replaces resolved values with their reference tokens. -When `SessionOptions.networkPolicy` is set, `curl`/`wget`, `git clone` remotes, and `BashPython` socket connections enforce the same built-in allowlist/private-range rules. +When `SessionOptions.networkPolicy` is set, `curl`/`wget`, `git clone` remotes, and `BashPython` socket connections enforce the same built-in default-off HTTP(S), allowlist, and private-range rules. When `SessionOptions.permissionHandler` is set, `curl` and `wget` ask it before outbound HTTP(S) requests, `git clone` asks it before remote clones, and `BashPython` asks it before socket connections. `data:` and jailed `file:` URLs do not trigger network checks. ## Command Behaviors and Notes diff --git a/Sources/Bash/BashSession+ControlFlow.swift b/Sources/Bash/BashSession+ControlFlow.swift index 6f5ee58..8652b4f 100644 --- a/Sources/Bash/BashSession+ControlFlow.swift +++ b/Sources/Bash/BashSession+ControlFlow.swift @@ -96,7 +96,16 @@ extension BashSession { switch loop.kind { case let .list(variableName, values): - for value in values { + for (offset, value) in values.enumerated() { + if let failure = await executionControlStore?.recordLoopIteration( + loopName: "for", + iteration: offset + 1 + ) { + combinedErr.append(Data("\(failure.message)\n".utf8)) + lastExitCode = failure.exitCode + break + } + environmentStore[variableName] = value let execution = await executeParsedLine( parsedLine: parsedBody, @@ -125,9 +134,12 @@ extension BashSession { var iterations = 0 while true { iterations += 1 - if iterations > 10_000 { - combinedErr.append(Data("for: exceeded max iterations\n".utf8)) - lastExitCode = 2 + if let failure = await executionControlStore?.recordLoopIteration( + loopName: "for", + iteration: iterations + ) { + combinedErr.append(Data("\(failure.message)\n".utf8)) + lastExitCode = failure.exitCode break } @@ -541,10 +553,13 @@ extension BashSession { var iterations = 0 while true { iterations += 1 - if iterations > 10_000 { - let loopName = loop.isUntil ? "until" : "while" - combinedErr.append(Data("\(loopName): exceeded max iterations\n".utf8)) - lastExitCode = 2 + let loopName = loop.isUntil ? "until" : "while" + if let failure = await executionControlStore?.recordLoopIteration( + loopName: loopName, + iteration: iterations + ) { + combinedErr.append(Data("\(failure.message)\n".utf8)) + lastExitCode = failure.exitCode break } diff --git a/Sources/Bash/BashSession+Expansion.swift b/Sources/Bash/BashSession+Expansion.swift index b8ed917..28962e9 100644 --- a/Sources/Bash/BashSession+Expansion.swift +++ b/Sources/Bash/BashSession+Expansion.swift @@ -5,6 +5,7 @@ extension BashSession { var commandLine: String var stderr: Data var error: ShellError? + var failure: ExecutionFailure? } struct PendingHereDocument { @@ -18,6 +19,15 @@ extension BashSession { } func expandCommandSubstitutions(in commandLine: String) async -> CommandSubstitutionOutcome { + if let failure = await executionControlStore?.checkpoint() { + return CommandSubstitutionOutcome( + commandLine: "", + stderr: Data("\(failure.message)\n".utf8), + error: nil, + failure: failure + ) + } + var output = "" var stderr = Data() var quote: QuoteKind = .none @@ -25,6 +35,15 @@ extension BashSession { var pendingHereDocuments: [PendingHereDocument] = [] while index < commandLine.endIndex { + if let failure = await executionControlStore?.checkpoint() { + return CommandSubstitutionOutcome( + commandLine: output, + stderr: Data("\(failure.message)\n".utf8), + error: nil, + failure: failure + ) + } + let character = commandLine[index] if character == "\\", quote != .single { @@ -140,17 +159,37 @@ extension BashSession { return CommandSubstitutionOutcome( commandLine: output, stderr: stderr, - error: nil + error: nil, + failure: nil ) } private func evaluateCommandSubstitution(_ command: String) async -> CommandSubstitutionOutcome { + if let failure = await executionControlStore?.pushCommandSubstitution() { + return CommandSubstitutionOutcome( + commandLine: "", + stderr: Data("\(failure.message)\n".utf8), + error: nil, + failure: failure + ) + } + let nested = await expandCommandSubstitutions(in: command) + await executionControlStore?.popCommandSubstitution() + if let failure = nested.failure { + return CommandSubstitutionOutcome( + commandLine: "", + stderr: nested.stderr, + error: nil, + failure: failure + ) + } if let error = nested.error { return CommandSubstitutionOutcome( commandLine: "", stderr: nested.stderr, - error: error + error: error, + failure: nil ) } @@ -161,13 +200,15 @@ extension BashSession { return CommandSubstitutionOutcome( commandLine: "", stderr: nested.stderr, - error: shellError + error: shellError, + failure: nil ) } catch { return CommandSubstitutionOutcome( commandLine: "", stderr: nested.stderr, - error: .parserError("\(error)") + error: .parserError("\(error)"), + failure: nil ) } @@ -189,7 +230,8 @@ extension BashSession { return CommandSubstitutionOutcome( commandLine: replacement, stderr: stderr, - error: nil + error: nil, + failure: nil ) } diff --git a/Sources/Bash/BashSession.swift b/Sources/Bash/BashSession.swift index 8514645..bf1448c 100644 --- a/Sources/Bash/BashSession.swift +++ b/Sources/Bash/BashSession.swift @@ -5,6 +5,7 @@ public final actor BashSession { private let options: SessionOptions let jobManager: ShellJobManager private let permissionAuthorizer: PermissionAuthorizer + var executionControlStore: ExecutionControl? var currentDirectoryStore: String var environmentStore: [String: String] @@ -44,8 +45,16 @@ public final actor BashSession { let usesTemporaryState = options.currentDirectory != nil || !options.environment.isEmpty || options.replaceEnvironment + let executionControl = ExecutionControl( + limits: options.executionLimits ?? self.options.executionLimits, + cancellationCheck: options.cancellationCheck + ) guard usesTemporaryState else { - return await runPersistingState(commandLine, stdin: options.stdin) + return await runPersistingState( + commandLine, + stdin: options.stdin, + executionControl: executionControl + ) } let savedCurrentDirectory = currentDirectoryStore @@ -82,7 +91,11 @@ public final actor BashSession { environmentStore.merge(options.environment) { _, rhs in rhs } } - let result = await runPersistingState(commandLine, stdin: options.stdin) + let result = await runPersistingState( + commandLine, + stdin: options.stdin, + executionControl: executionControl + ) currentDirectoryStore = savedCurrentDirectory environmentStore = savedEnvironment @@ -90,18 +103,41 @@ public final actor BashSession { return result } - private func runPersistingState(_ commandLine: String, stdin: Data) async -> CommandResult { + private func runPersistingState( + _ commandLine: String, + stdin: Data, + executionControl: ExecutionControl + ) async -> CommandResult { + let savedExecutionControl = executionControlStore + executionControlStore = executionControl + defer { executionControlStore = savedExecutionControl } + let trimmed = commandLine.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return CommandResult(stdout: Data(), stderr: Data(), exitCode: 0) } + if let failure = await executionControl.checkpoint() { + return CommandResult( + stdout: Data(), + stderr: Data("\(failure.message)\n".utf8), + exitCode: failure.exitCode + ) + } + historyStore.append(trimmed) if historyStore.count > options.maxHistory { historyStore.removeFirst(historyStore.count - options.maxHistory) } var substitution = await expandCommandSubstitutions(in: commandLine) + if let failure = substitution.failure { + return CommandResult( + stdout: Data(), + stderr: substitution.stderr, + exitCode: failure.exitCode + ) + } if let error = substitution.error { var stderr = substitution.stderr stderr.append(Data("\(error)\n".utf8)) @@ -241,6 +277,7 @@ public final actor BashSession { networkPolicy: options.networkPolicy, handler: options.permissionHandler ) + executionControlStore = nil commandRegistry = [:] shellFunctionStore = [:] @@ -315,6 +352,7 @@ public final actor BashSession { enableGlobbing: options.enableGlobbing, jobControl: jobControl, permissionAuthorizer: permissionAuthorizer, + executionControl: executionControlStore, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, diff --git a/Sources/Bash/Commands/BuiltinCommand.swift b/Sources/Bash/Commands/BuiltinCommand.swift index eb5ae05..fcd6159 100644 --- a/Sources/Bash/Commands/BuiltinCommand.swift +++ b/Sources/Bash/Commands/BuiltinCommand.swift @@ -20,6 +20,7 @@ public struct CommandContext: Sendable { let secretTracker: SecretExposureTracker? let jobControl: (any ShellJobControlling)? let permissionAuthorizer: any PermissionAuthorizing + let executionControl: ExecutionControl? public init( commandName: String, @@ -54,7 +55,8 @@ public struct CommandContext: Sendable { stderr: stderr, secretTracker: nil, jobControl: nil, - permissionAuthorizer: PermissionAuthorizer() + permissionAuthorizer: PermissionAuthorizer(), + executionControl: nil ) } @@ -75,7 +77,8 @@ public struct CommandContext: Sendable { stderr: Data = Data(), secretTracker: SecretExposureTracker?, jobControl: (any ShellJobControlling)? = nil, - permissionAuthorizer: any PermissionAuthorizing = PermissionAuthorizer() + permissionAuthorizer: any PermissionAuthorizing = PermissionAuthorizer(), + executionControl: ExecutionControl? = nil ) { self.commandName = commandName self.arguments = arguments @@ -94,6 +97,7 @@ public struct CommandContext: Sendable { self.secretTracker = secretTracker self.jobControl = jobControl self.permissionAuthorizer = permissionAuthorizer + self.executionControl = executionControl } public mutating func writeStdout(_ string: String) { @@ -222,6 +226,18 @@ public struct CommandContext: Sendable { } let commandArgs = Array(argv.dropFirst()) + if let failure = await executionControl?.recordCommandExecution(commandName: commandName) { + return ( + CommandResult( + stdout: Data(), + stderr: Data("\(failure.message)\n".utf8), + exitCode: failure.exitCode + ), + currentDirectory, + environment + ) + } + var childContext = CommandContext( commandName: commandName, arguments: commandArgs, @@ -237,7 +253,8 @@ public struct CommandContext: Sendable { stdin: stdin ?? self.stdin, secretTracker: secretTracker, jobControl: jobControl, - permissionAuthorizer: permissionAuthorizer + permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl ) let exitCode = await implementation.runCommand(&childContext, commandArgs) diff --git a/Sources/Bash/Core/ShellExecutor.swift b/Sources/Bash/Core/ShellExecutor.swift index c0ccb31..9e64995 100644 --- a/Sources/Bash/Core/ShellExecutor.swift +++ b/Sources/Bash/Core/ShellExecutor.swift @@ -10,6 +10,7 @@ private struct TextExpansionOutcome: Sendable { var text: String var stderr: Data var error: ShellError? + var failure: ExecutionFailure? } enum ShellExecutor { @@ -25,6 +26,7 @@ enum ShellExecutor { enableGlobbing: Bool, jobControl: (any ShellJobControlling)?, permissionAuthorizer: any PermissionAuthorizing, + executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -46,6 +48,12 @@ enum ShellExecutor { var lastExitCode: Int32 = 0 for segment in parsedLine.segments { + if let failure = await executionControl?.checkpoint() { + lastExitCode = failure.exitCode + aggregateErr.append(Data("\(failure.message)\n".utf8)) + break + } + if shouldSkipSegment(connector: segment.connector, previousExitCode: lastExitCode) { continue } @@ -81,6 +89,7 @@ enum ShellExecutor { enableGlobbing: backgroundEnableGlobbing, jobControl: nil, permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl, secretPolicy: backgroundSecretPolicy, secretResolver: backgroundSecretResolver, secretTracker: localTracker, @@ -117,6 +126,7 @@ enum ShellExecutor { enableGlobbing: enableGlobbing, jobControl: jobControl, permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -162,6 +172,7 @@ enum ShellExecutor { enableGlobbing: Bool, jobControl: (any ShellJobControlling)?, permissionAuthorizer: any PermissionAuthorizing, + executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -184,6 +195,7 @@ enum ShellExecutor { enableGlobbing: enableGlobbing, jobControl: jobControl, permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -212,11 +224,20 @@ enum ShellExecutor { enableGlobbing: Bool, jobControl: (any ShellJobControlling)?, permissionAuthorizer: any PermissionAuthorizing, + executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, secretOutputRedactor: any SecretOutputRedacting ) async -> CommandResult { + if let failure = await executionControl?.checkpoint() { + return CommandResult( + stdout: Data(), + stderr: Data("\(failure.message)\n".utf8), + exitCode: failure.exitCode + ) + } + var input = stdin var stderr = Data() @@ -232,12 +253,16 @@ enum ShellExecutor { shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, secretOutputRedactor: secretOutputRedactor ) stderr.append(expandedHereDocument.stderr) + if let failure = expandedHereDocument.failure { + return CommandResult(stdout: Data(), stderr: stderr, exitCode: failure.exitCode) + } if let error = expandedHereDocument.error { stderr.append(Data("\(error)\n".utf8)) return CommandResult(stdout: Data(), stderr: stderr, exitCode: 2) @@ -284,6 +309,14 @@ enum ShellExecutor { } else if commandName == "local" { result = executeLocalBuiltin(commandArgs, environment: &environment) } else if let implementation = resolveCommand(named: commandName, registry: commandRegistry) { + if let failure = await executionControl?.recordCommandExecution(commandName: commandName) { + return CommandResult( + stdout: Data(), + stderr: Data("\(failure.message)\n".utf8), + exitCode: failure.exitCode + ) + } + var context = CommandContext( commandName: commandName, arguments: commandArgs, @@ -299,7 +332,8 @@ enum ShellExecutor { stdin: input, secretTracker: secretTracker, jobControl: jobControl, - permissionAuthorizer: permissionAuthorizer + permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl ) let exitCode = await implementation.runCommand(&context, commandArgs) @@ -324,6 +358,7 @@ enum ShellExecutor { enableGlobbing: enableGlobbing, jobControl: jobControl, permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -450,6 +485,7 @@ enum ShellExecutor { enableGlobbing: Bool, jobControl: (any ShellJobControlling)?, permissionAuthorizer: any PermissionAuthorizing, + executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -466,6 +502,14 @@ enum ShellExecutor { ) } + if let failure = await executionControl?.pushFunction() { + return CommandResult( + stdout: Data(), + stderr: Data("\(failure.message)\n".utf8), + exitCode: failure.exitCode + ) + } + let savedEnvironment = environment let savedPositional = snapshotPositionalParameters(from: environment) let previousDepth = Int(environment[functionDepthKey] ?? "0") ?? 0 @@ -488,6 +532,7 @@ enum ShellExecutor { enableGlobbing: enableGlobbing, jobControl: jobControl, permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -512,6 +557,8 @@ enum ShellExecutor { environment[functionDepthKey] = String(previousDepth) } + await executionControl?.popFunction() + return execution.result } @@ -923,6 +970,7 @@ enum ShellExecutor { shellFunctions: [String: String], enableGlobbing: Bool, permissionAuthorizer: any PermissionAuthorizing, + executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -932,7 +980,8 @@ enum ShellExecutor { return TextExpansionOutcome( text: hereDocument.body, stderr: Data(), - error: nil + error: nil, + failure: nil ) } @@ -946,6 +995,7 @@ enum ShellExecutor { shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -963,6 +1013,7 @@ enum ShellExecutor { shellFunctions: [String: String], enableGlobbing: Bool, permissionAuthorizer: any PermissionAuthorizing, + executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -988,6 +1039,15 @@ enum ShellExecutor { } while index < text.endIndex { + if let failure = await executionControl?.checkpoint() { + return TextExpansionOutcome( + text: output, + stderr: Data("\(failure.message)\n".utf8), + error: nil, + failure: failure + ) + } + let character = text[index] if character == "\\" { @@ -1047,6 +1107,7 @@ enum ShellExecutor { shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -1058,7 +1119,16 @@ enum ShellExecutor { return TextExpansionOutcome( text: output, stderr: stderr, - error: error + error: error, + failure: nil + ) + } + if let failure = evaluated.failure { + return TextExpansionOutcome( + text: output, + stderr: stderr, + error: nil, + failure: failure ) } index = capture.endIndex @@ -1067,13 +1137,15 @@ enum ShellExecutor { return TextExpansionOutcome( text: output, stderr: stderr, - error: shellError + error: shellError, + failure: nil ) } catch { return TextExpansionOutcome( text: output, stderr: stderr, - error: .parserError("\(error)") + error: .parserError("\(error)"), + failure: nil ) } } @@ -1130,7 +1202,8 @@ enum ShellExecutor { return TextExpansionOutcome( text: output, stderr: stderr, - error: nil + error: nil, + failure: nil ) } @@ -1144,6 +1217,7 @@ enum ShellExecutor { shellFunctions: [String: String], enableGlobbing: Bool, permissionAuthorizer: any PermissionAuthorizing, + executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -1156,6 +1230,15 @@ enum ShellExecutor { var pendingHereDocuments: [PendingHereDocumentCapture] = [] while index < commandLine.endIndex { + if let failure = await executionControl?.checkpoint() { + return TextExpansionOutcome( + text: output, + stderr: Data("\(failure.message)\n".utf8), + error: nil, + failure: failure + ) + } + let character = commandLine[index] if character == "\\", quote != .single { @@ -1216,13 +1299,15 @@ enum ShellExecutor { return TextExpansionOutcome( text: output, stderr: stderr, - error: shellError + error: shellError, + failure: nil ) } catch { return TextExpansionOutcome( text: output, stderr: stderr, - error: .parserError("\(error)") + error: .parserError("\(error)"), + failure: nil ) } } @@ -1250,6 +1335,7 @@ enum ShellExecutor { shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -1261,7 +1347,16 @@ enum ShellExecutor { return TextExpansionOutcome( text: output, stderr: stderr, - error: error + error: error, + failure: nil + ) + } + if let failure = evaluated.failure { + return TextExpansionOutcome( + text: output, + stderr: stderr, + error: nil, + failure: failure ) } index = capture.endIndex @@ -1270,13 +1365,15 @@ enum ShellExecutor { return TextExpansionOutcome( text: output, stderr: stderr, - error: shellError + error: shellError, + failure: nil ) } catch { return TextExpansionOutcome( text: output, stderr: stderr, - error: .parserError("\(error)") + error: .parserError("\(error)"), + failure: nil ) } } @@ -1289,7 +1386,8 @@ enum ShellExecutor { return TextExpansionOutcome( text: output, stderr: stderr, - error: nil + error: nil, + failure: nil ) } @@ -1303,11 +1401,21 @@ enum ShellExecutor { shellFunctions: [String: String], enableGlobbing: Bool, permissionAuthorizer: any PermissionAuthorizing, + executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, secretOutputRedactor: any SecretOutputRedacting ) async -> TextExpansionOutcome { + if let failure = await executionControl?.pushCommandSubstitution() { + return TextExpansionOutcome( + text: "", + stderr: Data("\(failure.message)\n".utf8), + error: nil, + failure: failure + ) + } + let nested = await expandCommandSubstitutionsInCommandText( command, filesystem: filesystem, @@ -1318,11 +1426,21 @@ enum ShellExecutor { shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, secretOutputRedactor: secretOutputRedactor ) + await executionControl?.popCommandSubstitution() + if let failure = nested.failure { + return TextExpansionOutcome( + text: "", + stderr: nested.stderr, + error: nil, + failure: failure + ) + } if nested.error != nil { return nested } @@ -1334,13 +1452,15 @@ enum ShellExecutor { return TextExpansionOutcome( text: "", stderr: nested.stderr, - error: shellError + error: shellError, + failure: nil ) } catch { return TextExpansionOutcome( text: "", stderr: nested.stderr, - error: .parserError("\(error)") + error: .parserError("\(error)"), + failure: nil ) } @@ -1356,6 +1476,7 @@ enum ShellExecutor { enableGlobbing: enableGlobbing, jobControl: nil, permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -1368,7 +1489,8 @@ enum ShellExecutor { return TextExpansionOutcome( text: trimmingTrailingNewlines(from: execution.result.stdoutString), stderr: stderr, - error: nil + error: nil, + failure: nil ) } diff --git a/Sources/Bash/FS/MountableFilesystem.swift b/Sources/Bash/FS/MountableFilesystem.swift new file mode 100644 index 0000000..d3cf2ed --- /dev/null +++ b/Sources/Bash/FS/MountableFilesystem.swift @@ -0,0 +1,359 @@ +import Foundation + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +public final class MountableFilesystem: SessionConfigurableFilesystem, @unchecked Sendable { + public struct Mount: Sendable { + public var mountPoint: String + public var filesystem: any ShellFilesystem + + public init(mountPoint: String, filesystem: any ShellFilesystem) { + self.mountPoint = PathUtils.normalize(path: mountPoint, currentDirectory: "/") + self.filesystem = filesystem + } + } + + private let base: any ShellFilesystem + private var mounts: [Mount] + + public init( + base: any ShellFilesystem = InMemoryFilesystem(), + mounts: [Mount] = [] + ) { + self.base = base + self.mounts = mounts.sorted { $0.mountPoint.count > $1.mountPoint.count } + } + + public func mount(_ mountPoint: String, filesystem: any ShellFilesystem) { + let mount = Mount(mountPoint: mountPoint, filesystem: filesystem) + mounts.append(mount) + mounts.sort { $0.mountPoint.count > $1.mountPoint.count } + } + + public func configure(rootDirectory: URL) throws { + try base.configure(rootDirectory: rootDirectory) + for mount in mounts { + if let configurable = mount.filesystem as? any SessionConfigurableFilesystem { + try configurable.configureForSession() + } + } + } + + public func configureForSession() throws { + guard let configurableBase = base as? any SessionConfigurableFilesystem else { + throw ShellError.unsupported("filesystem requires rootDirectory initializer") + } + try configurableBase.configureForSession() + for mount in mounts { + if let configurable = mount.filesystem as? any SessionConfigurableFilesystem { + try configurable.configureForSession() + } + } + } + + public func stat(path: String) async throws -> FileInfo { + let normalized = PathUtils.normalize(path: path, currentDirectory: "/") + if let resolved = resolveMounted(path: normalized) { + var info = try await resolved.filesystem.stat(path: resolved.relativePath) + info.path = normalized + return info + } + + if hasSyntheticDirectory(at: normalized) { + return FileInfo( + path: normalized, + isDirectory: true, + isSymbolicLink: false, + size: 0, + permissions: 0o755, + modificationDate: nil + ) + } + + return try await base.stat(path: normalized) + } + + public func listDirectory(path: String) async throws -> [DirectoryEntry] { + let normalized = PathUtils.normalize(path: path, currentDirectory: "/") + if let resolved = resolveMounted(path: normalized) { + let entries = try await resolved.filesystem.listDirectory(path: resolved.relativePath) + return entries.map { entry in + DirectoryEntry( + name: entry.name, + info: FileInfo( + path: PathUtils.join(normalized, entry.name), + isDirectory: entry.info.isDirectory, + isSymbolicLink: entry.info.isSymbolicLink, + size: entry.info.size, + permissions: entry.info.permissions, + modificationDate: entry.info.modificationDate + ) + ) + } + } + + var merged: [String: DirectoryEntry] = [:] + let baseHasPath = normalized == "/" ? true : await base.exists(path: normalized) + if baseHasPath { + if let baseEntries = try? await base.listDirectory(path: normalized) { + for entry in baseEntries { + merged[entry.name] = entry + } + } + } + + for syntheticName in syntheticChildMountNames(under: normalized) { + merged[syntheticName] = DirectoryEntry( + name: syntheticName, + info: FileInfo( + path: PathUtils.join(normalized, syntheticName), + isDirectory: true, + isSymbolicLink: false, + size: 0, + permissions: 0o755, + modificationDate: nil + ) + ) + } + + if merged.isEmpty, !hasSyntheticDirectory(at: normalized) { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOENT)) + } + + return merged.values.sorted { $0.name < $1.name } + } + + public func readFile(path: String) async throws -> Data { + let normalized = PathUtils.normalize(path: path, currentDirectory: "/") + if let resolved = resolveMounted(path: normalized) { + return try await resolved.filesystem.readFile(path: resolved.relativePath) + } + return try await base.readFile(path: normalized) + } + + public func writeFile(path: String, data: Data, append: Bool) async throws { + let normalized = PathUtils.normalize(path: path, currentDirectory: "/") + let resolved = resolveWritable(path: normalized) + try await resolved.filesystem.writeFile(path: resolved.relativePath, data: data, append: append) + } + + public func createDirectory(path: String, recursive: Bool) async throws { + let normalized = PathUtils.normalize(path: path, currentDirectory: "/") + let resolved = resolveWritable(path: normalized) + try await resolved.filesystem.createDirectory(path: resolved.relativePath, recursive: recursive) + } + + public func remove(path: String, recursive: Bool) async throws { + let normalized = PathUtils.normalize(path: path, currentDirectory: "/") + let resolved = resolveWritable(path: normalized) + try await resolved.filesystem.remove(path: resolved.relativePath, recursive: recursive) + } + + public func move(from sourcePath: String, to destinationPath: String) async throws { + let source = resolveWritable(path: PathUtils.normalize(path: sourcePath, currentDirectory: "/")) + let destination = resolveWritable(path: PathUtils.normalize(path: destinationPath, currentDirectory: "/")) + if source.mountPoint == destination.mountPoint { + try await source.filesystem.move(from: source.relativePath, to: destination.relativePath) + return + } + + try await copyTree( + from: source.filesystem, + sourcePath: source.relativePath, + to: destination.filesystem, + destinationPath: destination.relativePath + ) + try await source.filesystem.remove(path: source.relativePath, recursive: true) + } + + public func copy(from sourcePath: String, to destinationPath: String, recursive: Bool) async throws { + let source = resolveWritable(path: PathUtils.normalize(path: sourcePath, currentDirectory: "/")) + let destination = resolveWritable(path: PathUtils.normalize(path: destinationPath, currentDirectory: "/")) + if source.mountPoint == destination.mountPoint { + try await source.filesystem.copy( + from: source.relativePath, + to: destination.relativePath, + recursive: recursive + ) + return + } + + let info = try await source.filesystem.stat(path: source.relativePath) + if info.isDirectory, !recursive { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(EISDIR)) + } + try await copyTree( + from: source.filesystem, + sourcePath: source.relativePath, + to: destination.filesystem, + destinationPath: destination.relativePath + ) + } + + public func createSymlink(path: String, target: String) async throws { + let resolved = resolveWritable(path: PathUtils.normalize(path: path, currentDirectory: "/")) + try await resolved.filesystem.createSymlink(path: resolved.relativePath, target: target) + } + + public func createHardLink(path: String, target: String) async throws { + let link = resolveWritable(path: PathUtils.normalize(path: path, currentDirectory: "/")) + let targetResolved = resolveWritable(path: PathUtils.normalize(path: target, currentDirectory: "/")) + if link.mountPoint != targetResolved.mountPoint { + throw ShellError.unsupported("hard links across mounts are not supported") + } + try await link.filesystem.createHardLink(path: link.relativePath, target: targetResolved.relativePath) + } + + public func readSymlink(path: String) async throws -> String { + let resolved = resolveWritable(path: PathUtils.normalize(path: path, currentDirectory: "/")) + return try await resolved.filesystem.readSymlink(path: resolved.relativePath) + } + + public func setPermissions(path: String, permissions: Int) async throws { + let resolved = resolveWritable(path: PathUtils.normalize(path: path, currentDirectory: "/")) + try await resolved.filesystem.setPermissions(path: resolved.relativePath, permissions: permissions) + } + + public func resolveRealPath(path: String) async throws -> String { + let normalized = PathUtils.normalize(path: path, currentDirectory: "/") + let resolved = resolveWritable(path: normalized) + let real = try await resolved.filesystem.resolveRealPath(path: resolved.relativePath) + return resolved.mountPoint == "/" ? real : PathUtils.join(resolved.mountPoint, String(real.dropFirst())) + } + + public func exists(path: String) async -> Bool { + let normalized = PathUtils.normalize(path: path, currentDirectory: "/") + if let resolved = resolveMounted(path: normalized) { + return await resolved.filesystem.exists(path: resolved.relativePath) + } + if hasSyntheticDirectory(at: normalized) { + return true + } + return await base.exists(path: normalized) + } + + public func glob(pattern: String, currentDirectory: String) async throws -> [String] { + let normalizedPattern = PathUtils.normalize(path: pattern, currentDirectory: currentDirectory) + if !PathUtils.containsGlob(normalizedPattern) { + return await exists(path: normalizedPattern) ? [normalizedPattern] : [] + } + + let regex = try NSRegularExpression(pattern: PathUtils.globToRegex(normalizedPattern)) + let paths = try await allPaths() + return paths.filter { path in + let range = NSRange(path.startIndex.. [String] { + var visited = Set() + var queue = ["/"] + var paths = ["/"] + + while let current = queue.first { + queue.removeFirst() + if visited.contains(current) { + continue + } + visited.insert(current) + + guard let entries = try? await listDirectory(path: current) else { + continue + } + + for entry in entries { + let childPath = PathUtils.join(current, entry.name) + paths.append(childPath) + if entry.info.isDirectory { + queue.append(childPath) + } + } + } + + return Array(Set(paths)) + } + + private func hasSyntheticDirectory(at path: String) -> Bool { + path == "/" || mounts.contains { parentPath(of: $0.mountPoint) == path } || syntheticChildMountNames(under: path).isEmpty == false + } + + private func syntheticChildMountNames(under path: String) -> [String] { + var names = Set() + for mount in mounts where mount.mountPoint != path { + guard isPath(mount.mountPoint, inside: path) else { + continue + } + let remaining = mount.mountPoint == "/" ? "" : String(mount.mountPoint.dropFirst(path == "/" ? 1 : path.count + 1)) + guard !remaining.isEmpty else { continue } + if let first = remaining.split(separator: "/").first { + names.insert(String(first)) + } + } + return names.sorted() + } + + private func parentPath(of path: String) -> String { + PathUtils.dirname(path) + } + + private func isPath(_ candidate: String, inside parent: String) -> Bool { + if parent == "/" { + return candidate.hasPrefix("/") && candidate != "/" + } + return candidate == parent || candidate.hasPrefix(parent + "/") + } + + private func resolveWritable(path: String) -> (mountPoint: String, filesystem: any ShellFilesystem, relativePath: String) { + resolveMounted(path: path) ?? ("/", base, path) + } + + private func resolveMounted(path: String) -> (mountPoint: String, filesystem: any ShellFilesystem, relativePath: String)? { + for mount in mounts { + if mount.mountPoint == path { + return (mount.mountPoint, mount.filesystem, "/") + } + + if mount.mountPoint != "/", path.hasPrefix(mount.mountPoint + "/") { + let suffix = String(path.dropFirst(mount.mountPoint.count)) + return (mount.mountPoint, mount.filesystem, suffix.isEmpty ? "/" : suffix) + } + } + return nil + } +} diff --git a/Sources/Bash/FS/OverlayFilesystem.swift b/Sources/Bash/FS/OverlayFilesystem.swift new file mode 100644 index 0000000..87be87e --- /dev/null +++ b/Sources/Bash/FS/OverlayFilesystem.swift @@ -0,0 +1,172 @@ +import Foundation + +public final class OverlayFilesystem: SessionConfigurableFilesystem, @unchecked Sendable { + private let fileManager: FileManager + private let overlay: InMemoryFilesystem + private var rootURL: URL? + + public init(fileManager: FileManager = .default) { + self.fileManager = fileManager + overlay = InMemoryFilesystem() + } + + public convenience init(rootDirectory: URL, fileManager: FileManager = .default) throws { + self.init(fileManager: fileManager) + try configure(rootDirectory: rootDirectory) + } + + public func configure(rootDirectory: URL) throws { + rootURL = rootDirectory.standardizedFileURL + try rebuildOverlay() + } + + public func configureForSession() throws { + guard rootURL != nil else { + throw ShellError.unsupported("overlay filesystem requires rootDirectory") + } + try rebuildOverlay() + } + + public func stat(path: String) async throws -> FileInfo { + try await overlay.stat(path: path) + } + + public func listDirectory(path: String) async throws -> [DirectoryEntry] { + try await overlay.listDirectory(path: path) + } + + public func readFile(path: String) async throws -> Data { + try await overlay.readFile(path: path) + } + + public func writeFile(path: String, data: Data, append: Bool) async throws { + try await overlay.writeFile(path: path, data: data, append: append) + } + + public func createDirectory(path: String, recursive: Bool) async throws { + try await overlay.createDirectory(path: path, recursive: recursive) + } + + public func remove(path: String, recursive: Bool) async throws { + try await overlay.remove(path: path, recursive: recursive) + } + + public func move(from sourcePath: String, to destinationPath: String) async throws { + try await overlay.move(from: sourcePath, to: destinationPath) + } + + public func copy(from sourcePath: String, to destinationPath: String, recursive: Bool) async throws { + try await overlay.copy(from: sourcePath, to: destinationPath, recursive: recursive) + } + + public func createSymlink(path: String, target: String) async throws { + try await overlay.createSymlink(path: path, target: target) + } + + public func createHardLink(path: String, target: String) async throws { + try await overlay.createHardLink(path: path, target: target) + } + + public func readSymlink(path: String) async throws -> String { + try await overlay.readSymlink(path: path) + } + + public func setPermissions(path: String, permissions: Int) async throws { + try await overlay.setPermissions(path: path, permissions: permissions) + } + + public func resolveRealPath(path: String) async throws -> String { + try await overlay.resolveRealPath(path: path) + } + + public func exists(path: String) async -> Bool { + await overlay.exists(path: path) + } + + public func glob(pattern: String, currentDirectory: String) async throws -> [String] { + try await overlay.glob(pattern: pattern, currentDirectory: currentDirectory) + } + + private func rebuildOverlay() throws { + try overlay.configureForSession() + + guard let rootURL else { + return + } + + guard fileManager.fileExists(atPath: rootURL.path) else { + return + } + + let names = try fileManager.contentsOfDirectory(atPath: rootURL.path).sorted() + for name in names { + let childURL = rootURL.appendingPathComponent(name, isDirectory: true) + try importItem(at: childURL, virtualPath: "/" + name) + } + } + + private func importItem(at url: URL, virtualPath: String) throws { + let values = try url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + let attributes = try fileManager.attributesOfItem(atPath: url.path) + let permissions = (attributes[.posixPermissions] as? NSNumber)?.intValue + + if values.isSymbolicLink == true { + let target = try fileManager.destinationOfSymbolicLink(atPath: url.path) + try performAsync { + try await self.overlay.createSymlink(path: virtualPath, target: target) + if let permissions { + try await self.overlay.setPermissions(path: virtualPath, permissions: permissions) + } + } + return + } + + if values.isDirectory == true { + try performAsync { + try await self.overlay.createDirectory(path: virtualPath, recursive: true) + if let permissions { + try await self.overlay.setPermissions(path: virtualPath, permissions: permissions) + } + } + + let children = try fileManager.contentsOfDirectory(atPath: url.path).sorted() + for child in children { + let childURL = url.appendingPathComponent(child, isDirectory: true) + try importItem(at: childURL, virtualPath: PathUtils.join(virtualPath, child)) + } + return + } + + let data = try Data(contentsOf: url) + try performAsync { + try await self.overlay.writeFile(path: virtualPath, data: data, append: false) + if let permissions { + try await self.overlay.setPermissions(path: virtualPath, permissions: permissions) + } + } + } + + private func performAsync( + _ operation: @escaping @Sendable () async throws -> Void + ) throws { + let semaphore = DispatchSemaphore(value: 0) + final class ErrorBox: @unchecked Sendable { + var error: Error? + } + let box = ErrorBox() + + Task { + defer { semaphore.signal() } + do { + try await operation() + } catch { + box.error = error + } + } + + semaphore.wait() + if let error = box.error { + throw error + } + } +} diff --git a/Sources/Bash/Support/ExecutionControl.swift b/Sources/Bash/Support/ExecutionControl.swift new file mode 100644 index 0000000..08f7474 --- /dev/null +++ b/Sources/Bash/Support/ExecutionControl.swift @@ -0,0 +1,99 @@ +import Foundation + +struct ExecutionFailure: Sendable { + let exitCode: Int32 + let message: String +} + +actor ExecutionControl { + nonisolated let limits: ExecutionLimits + private let cancellationCheck: (@Sendable () -> Bool)? + + private var commandCount = 0 + private var functionDepth = 0 + private var commandSubstitutionDepth = 0 + + init( + limits: ExecutionLimits, + cancellationCheck: (@Sendable () -> Bool)? = nil + ) { + self.limits = limits + self.cancellationCheck = cancellationCheck + } + + func checkpoint() -> ExecutionFailure? { + if Task.isCancelled || cancellationCheck?() == true { + return ExecutionFailure(exitCode: 130, message: "execution cancelled") + } + return nil + } + + func recordCommandExecution(commandName: String) -> ExecutionFailure? { + if let failure = checkpoint() { + return failure + } + + commandCount += 1 + guard commandCount <= limits.maxCommandCount else { + return ExecutionFailure( + exitCode: 2, + message: "execution limit exceeded: maximum command count (\(limits.maxCommandCount))" + ) + } + return nil + } + + func recordLoopIteration(loopName: String, iteration: Int) -> ExecutionFailure? { + if let failure = checkpoint() { + return failure + } + + guard iteration <= limits.maxLoopIterations else { + return ExecutionFailure( + exitCode: 2, + message: "\(loopName): exceeded max iterations" + ) + } + return nil + } + + func pushFunction() -> ExecutionFailure? { + if let failure = checkpoint() { + return failure + } + + functionDepth += 1 + guard functionDepth <= limits.maxFunctionDepth else { + functionDepth -= 1 + return ExecutionFailure( + exitCode: 2, + message: "execution limit exceeded: maximum function depth (\(limits.maxFunctionDepth))" + ) + } + return nil + } + + func popFunction() { + functionDepth = max(0, functionDepth - 1) + } + + func pushCommandSubstitution() -> ExecutionFailure? { + if let failure = checkpoint() { + return failure + } + + commandSubstitutionDepth += 1 + guard commandSubstitutionDepth <= limits.maxCommandSubstitutionDepth else { + commandSubstitutionDepth -= 1 + return ExecutionFailure( + exitCode: 2, + message: "execution limit exceeded: maximum command substitution depth (\(limits.maxCommandSubstitutionDepth))" + ) + } + return nil + } + + func popCommandSubstitution() { + commandSubstitutionDepth = max(0, commandSubstitutionDepth - 1) + } +} diff --git a/Sources/Bash/Support/Permissions.swift b/Sources/Bash/Support/Permissions.swift index 362514d..8c48968 100644 --- a/Sources/Bash/Support/Permissions.swift +++ b/Sources/Bash/Support/Permissions.swift @@ -31,17 +31,21 @@ public struct NetworkPermissionRequest: Sendable, Hashable { } public struct NetworkPolicy: Sendable { - public static let unrestricted = NetworkPolicy() + public static let disabled = NetworkPolicy() + public static let unrestricted = NetworkPolicy(allowsHTTPRequests: true) + public var allowsHTTPRequests: Bool public var allowedHosts: [String] public var allowedURLPrefixes: [String] public var denyPrivateRanges: Bool public init( + allowsHTTPRequests: Bool = false, allowedHosts: [String] = [], allowedURLPrefixes: [String] = [], denyPrivateRanges: Bool = false ) { + self.allowsHTTPRequests = allowsHTTPRequests self.allowedHosts = allowedHosts self.allowedURLPrefixes = allowedURLPrefixes self.denyPrivateRanges = denyPrivateRanges @@ -70,7 +74,7 @@ actor PermissionAuthorizer: PermissionAuthorizing { private var sessionAllows: Set = [] init( - networkPolicy: NetworkPolicy = .unrestricted, + networkPolicy: NetworkPolicy = .disabled, handler: Handler? = nil ) { self.networkPolicy = networkPolicy @@ -118,32 +122,34 @@ private enum PermissionPolicyEvaluator { for request: NetworkPermissionRequest, networkPolicy: NetworkPolicy ) -> String? { - guard networkPolicy.denyPrivateRanges || networkPolicy.hasAllowlist else { - return nil + guard networkPolicy.allowsHTTPRequests else { + return "network access denied by policy: outbound HTTP(S) access is disabled" } let host = parsedHost(from: request.url) + if networkPolicy.hasAllowlist { + let prefixAllowed = networkPolicy.allowedURLPrefixes.contains { + urlMatchesPrefix(request.url, allowedPrefix: $0) + } + let hostAllowed = if let host { + hostIsAllowed(host, allowedHosts: networkPolicy.allowedHosts) + } else { + false + } + + guard prefixAllowed || hostAllowed else { + return "network access denied by policy: '\(request.url)' is not in the network allowlist" + } + } + if networkPolicy.denyPrivateRanges, let host, hostTargetsPrivateRange(host) { return "network access denied by policy: private network host '\(host)'" } - guard networkPolicy.hasAllowlist else { - return nil - } - - if networkPolicy.allowedURLPrefixes.contains(where: { request.url.hasPrefix($0) }) { - return nil - } - - if let host, - hostIsAllowed(host, allowedHosts: networkPolicy.allowedHosts) { - return nil - } - - return "network access denied by policy: '\(request.url)' is not in the network allowlist" + return nil } private static func parsedHost(from urlString: String) -> String? { @@ -161,6 +167,67 @@ private enum PermissionPolicyEvaluator { return false } + private static func urlMatchesPrefix(_ urlString: String, allowedPrefix: String) -> Bool { + guard + let request = URLComponents(string: urlString), + let allowed = URLComponents(string: allowedPrefix), + let requestScheme = request.scheme?.lowercased(), + let allowedScheme = allowed.scheme?.lowercased(), + let requestHost = request.host?.lowercased(), + let allowedHost = allowed.host?.lowercased() + else { + return false + } + + guard requestScheme == allowedScheme, requestHost == allowedHost else { + return false + } + + if effectivePort(for: request) != effectivePort(for: allowed) { + return false + } + + let allowedPath = normalizedPrefixPath(allowed.path) + let requestPath = normalizedPrefixPath(request.path) + if allowedPath != "/", hasAmbiguousEncodedSeparator(in: request.percentEncodedPath) { + return false + } + + if allowedPath == "/" { + return true + } + + if allowedPath.hasSuffix("/") { + return requestPath.hasPrefix(allowedPath) + } + + return requestPath == allowedPath || requestPath.hasPrefix(allowedPath + "/") + } + + private static func normalizedPrefixPath(_ path: String) -> String { + path.isEmpty ? "/" : path + } + + private static func effectivePort(for components: URLComponents) -> Int? { + if let port = components.port { + return port + } + + switch components.scheme?.lowercased() { + case "http": + return 80 + case "https": + return 443 + default: + return nil + } + } + + private static func hasAmbiguousEncodedSeparator(in percentEncodedPath: String) -> Bool { + let lower = percentEncodedPath.lowercased() + return lower.contains("%2f") || lower.contains("%5c") + } + private static func hostTargetsPrivateRange(_ host: String) -> Bool { let normalized = host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "[]")) diff --git a/Sources/Bash/Support/Types.swift b/Sources/Bash/Support/Types.swift index e387d35..0716430 100644 --- a/Sources/Bash/Support/Types.swift +++ b/Sources/Bash/Support/Types.swift @@ -25,17 +25,44 @@ public struct RunOptions: Sendable { public var environment: [String: String] public var replaceEnvironment: Bool public var currentDirectory: String? + public var executionLimits: ExecutionLimits? + public var cancellationCheck: (@Sendable () -> Bool)? public init( stdin: Data = Data(), environment: [String: String] = [:], replaceEnvironment: Bool = false, - currentDirectory: String? = nil + currentDirectory: String? = nil, + executionLimits: ExecutionLimits? = nil, + cancellationCheck: (@Sendable () -> Bool)? = nil ) { self.stdin = stdin self.environment = environment self.replaceEnvironment = replaceEnvironment self.currentDirectory = currentDirectory + self.executionLimits = executionLimits + self.cancellationCheck = cancellationCheck + } +} + +public struct ExecutionLimits: Sendable { + public static let `default` = ExecutionLimits() + + public var maxCommandCount: Int + public var maxFunctionDepth: Int + public var maxLoopIterations: Int + public var maxCommandSubstitutionDepth: Int + + public init( + maxCommandCount: Int = 10_000, + maxFunctionDepth: Int = 100, + maxLoopIterations: Int = 10_000, + maxCommandSubstitutionDepth: Int = 32 + ) { + self.maxCommandCount = maxCommandCount + self.maxFunctionDepth = maxFunctionDepth + self.maxLoopIterations = maxLoopIterations + self.maxCommandSubstitutionDepth = maxCommandSubstitutionDepth } } @@ -115,6 +142,7 @@ public struct SessionOptions: Sendable { public var enableGlobbing: Bool public var maxHistory: Int public var networkPolicy: NetworkPolicy + public var executionLimits: ExecutionLimits public var permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? public var secretPolicy: SecretHandlingPolicy public var secretResolver: (any SecretReferenceResolving)? @@ -126,7 +154,8 @@ public struct SessionOptions: Sendable { initialEnvironment: [String: String] = [:], enableGlobbing: Bool = true, maxHistory: Int = 1_000, - networkPolicy: NetworkPolicy = .unrestricted, + networkPolicy: NetworkPolicy = .disabled, + executionLimits: ExecutionLimits = .default, permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? = nil, secretPolicy: SecretHandlingPolicy = .off, secretResolver: (any SecretReferenceResolving)? = nil, @@ -138,6 +167,7 @@ public struct SessionOptions: Sendable { self.enableGlobbing = enableGlobbing self.maxHistory = maxHistory self.networkPolicy = networkPolicy + self.executionLimits = executionLimits self.permissionHandler = permissionHandler self.secretPolicy = secretPolicy self.secretResolver = secretResolver diff --git a/Tests/BashGitTests/GitCommandTests.swift b/Tests/BashGitTests/GitCommandTests.swift index d3a6037..5b0dbea 100644 --- a/Tests/BashGitTests/GitCommandTests.swift +++ b/Tests/BashGitTests/GitCommandTests.swift @@ -148,7 +148,10 @@ struct GitCommandTests { @Test("clone remote repository respects network policy") func cloneRemoteRepositoryRespectsNetworkPolicy() async throws { let (session, root) = try await GitTestSupport.makeReadWriteSession( - networkPolicy: NetworkPolicy(denyPrivateRanges: true) + networkPolicy: NetworkPolicy( + allowsHTTPRequests: true, + denyPrivateRanges: true + ) ) defer { GitTestSupport.removeDirectory(root) } @@ -160,7 +163,10 @@ struct GitCommandTests { @Test("clone ssh-style repository respects host allowlist") func cloneSSHStyleRepositoryRespectsHostAllowlist() async throws { let (session, root) = try await GitTestSupport.makeReadWriteSession( - networkPolicy: NetworkPolicy(allowedHosts: ["gitlab.com"]) + networkPolicy: NetworkPolicy( + allowsHTTPRequests: true, + allowedHosts: ["gitlab.com"] + ) ) defer { GitTestSupport.removeDirectory(root) } diff --git a/Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift b/Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift index 3e0565a..5c78a33 100644 --- a/Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift +++ b/Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift @@ -115,7 +115,10 @@ struct CPythonRuntimeIntegrationTests { @BashPythonTestActor func networkPolicyBlocksPrivateSocketTargets() async throws { let (session, root) = try await PythonTestSupport.makeSession( - networkPolicy: NetworkPolicy(denyPrivateRanges: true) + networkPolicy: NetworkPolicy( + allowsHTTPRequests: true, + denyPrivateRanges: true + ) ) defer { PythonTestSupport.removeDirectory(root) } @@ -128,7 +131,10 @@ struct CPythonRuntimeIntegrationTests { @BashPythonTestActor func pythonNetworkChecksReuseHostCallbackAfterPolicyPasses() async throws { let (session, root) = try await PythonTestSupport.makeSession( - networkPolicy: NetworkPolicy(allowedHosts: ["1.1.1.1"]), + networkPolicy: NetworkPolicy( + allowsHTTPRequests: true, + allowedHosts: ["1.1.1.1"] + ), permissionHandler: { request in switch request.kind { case let .network(network): diff --git a/Tests/BashSecretsTests/SecretsCommandTests.swift b/Tests/BashSecretsTests/SecretsCommandTests.swift index 04c1bb0..a7e916a 100644 --- a/Tests/BashSecretsTests/SecretsCommandTests.swift +++ b/Tests/BashSecretsTests/SecretsCommandTests.swift @@ -400,7 +400,10 @@ struct SecretsCommandTests { @Test("curl resolves secret refs and redacts verbose output") func curlResolvesSecretRefsAndRedactsVerboseOutput() async throws { - let (session, root) = try await SecretsTestSupport.makeSecretAwareSession(policy: .resolveAndRedact) + let (session, root) = try await SecretsTestSupport.makeSecretAwareSession( + policy: .resolveAndRedact, + networkPolicy: .unrestricted + ) defer { SecretsTestSupport.removeDirectory(root) } let secretValue = "curl-secret-\(UUID().uuidString)" diff --git a/Tests/BashSecretsTests/TestSupport.swift b/Tests/BashSecretsTests/TestSupport.swift index 6088101..fb29acb 100644 --- a/Tests/BashSecretsTests/TestSupport.swift +++ b/Tests/BashSecretsTests/TestSupport.swift @@ -21,12 +21,14 @@ enum SecretsTestSupport { } static func makeSecretAwareSession( - policy: SecretHandlingPolicy + policy: SecretHandlingPolicy, + networkPolicy: NetworkPolicy = .disabled ) async throws -> (session: BashSession, root: URL) { try await makeSession( options: SessionOptions( filesystem: ReadWriteFilesystem(), layout: .unixLike, + networkPolicy: networkPolicy, secretPolicy: policy, secretResolver: BashSecretsReferenceResolver() ) diff --git a/Tests/BashTests/FilesystemOptionsTests.swift b/Tests/BashTests/FilesystemOptionsTests.swift index 650e8de..e5d5e2f 100644 --- a/Tests/BashTests/FilesystemOptionsTests.swift +++ b/Tests/BashTests/FilesystemOptionsTests.swift @@ -24,6 +24,82 @@ struct FilesystemOptionsTests { #expect(ls.stdoutString.contains("rootless.txt")) } + @Test("overlay filesystem snapshots disk and keeps writes in memory") + func overlayFilesystemSnapshotsDiskAndKeepsWritesInMemory() async throws { + let root = try TestSupport.makeTempDirectory(prefix: "BashOverlay") + defer { TestSupport.removeDirectory(root) } + + let onDisk = root.appendingPathComponent("seed.txt") + try Data("seed".utf8).write(to: onDisk) + + let session = try await BashSession( + options: SessionOptions( + filesystem: try OverlayFilesystem(rootDirectory: root), + layout: .rootOnly + ) + ) + + let read = await session.run("cat /seed.txt") + #expect(read.exitCode == 0) + #expect(read.stdoutString == "seed") + + let write = await session.run("printf updated > /seed.txt") + #expect(write.exitCode == 0) + + let overlayRead = await session.run("cat /seed.txt") + #expect(overlayRead.exitCode == 0) + #expect(overlayRead.stdoutString == "updated") + + let diskContents = try String(contentsOf: onDisk, encoding: .utf8) + #expect(diskContents == "seed") + } + + @Test("mountable filesystem can combine roots and copy across mounts") + func mountableFilesystemCanCombineRootsAndCopyAcrossMounts() async throws { + let base = InMemoryFilesystem() + let workspaceRoot = try TestSupport.makeTempDirectory(prefix: "BashMountWorkspace") + defer { TestSupport.removeDirectory(workspaceRoot) } + + let docsRoot = try TestSupport.makeTempDirectory(prefix: "BashMountDocs") + defer { TestSupport.removeDirectory(docsRoot) } + try Data("guide".utf8).write(to: docsRoot.appendingPathComponent("guide.txt")) + + let mountable = MountableFilesystem( + base: base, + mounts: [ + MountableFilesystem.Mount( + mountPoint: "/workspace", + filesystem: try OverlayFilesystem(rootDirectory: workspaceRoot) + ), + MountableFilesystem.Mount( + mountPoint: "/docs", + filesystem: try OverlayFilesystem(rootDirectory: docsRoot) + ), + ] + ) + + let session = try await BashSession( + options: SessionOptions( + filesystem: mountable, + layout: .rootOnly + ) + ) + + let top = await session.run("ls /") + #expect(top.exitCode == 0) + #expect(top.stdoutString.contains("workspace")) + #expect(top.stdoutString.contains("docs")) + + let copy = await session.run("cp /docs/guide.txt /workspace/guide.txt") + #expect(copy.exitCode == 0) + + let read = await session.run("cat /workspace/guide.txt") + #expect(read.exitCode == 0) + #expect(read.stdoutString == "guide") + + #expect(!FileManager.default.fileExists(atPath: workspaceRoot.appendingPathComponent("guide.txt").path)) + } + @Test("rootless session init rejects non-configurable filesystem") func rootlessSessionInitRejectsNonConfigurableFilesystem() async { do { diff --git a/Tests/BashTests/SessionIntegrationTests.swift b/Tests/BashTests/SessionIntegrationTests.swift index 94b1e12..ac93684 100644 --- a/Tests/BashTests/SessionIntegrationTests.swift +++ b/Tests/BashTests/SessionIntegrationTests.swift @@ -1418,7 +1418,7 @@ struct SessionIntegrationTests { @Test("curl command basic data and file usage") func curlCommandBasicDataAndFileUsage() async throws { - let (session, root) = try await TestSupport.makeSession() + let (session, root) = try await TestSupport.makeSession(networkPolicy: .unrestricted) defer { TestSupport.removeDirectory(root) } let dataURL = await session.run("curl data:text/plain,hello%20world") @@ -1550,6 +1550,7 @@ struct SessionIntegrationTests { func curlPermissionHandlerCanDenyOutboundHTTPRequests() async throws { let probe = PermissionProbe() let (session, root) = try await TestSupport.makeSession( + networkPolicy: .unrestricted, permissionHandler: { request in await probe.record(request) return .deny(message: "network access denied") @@ -1575,6 +1576,7 @@ struct SessionIntegrationTests { func curlPermissionHandlerAllowOnceDoesNotPersist() async throws { let probe = PermissionProbe() let (session, root) = try await TestSupport.makeSession( + networkPolicy: .unrestricted, permissionHandler: { request in await probe.record(request) return .allow @@ -1596,6 +1598,7 @@ struct SessionIntegrationTests { func curlPermissionHandlerCanAllowForSession() async throws { let probe = PermissionProbe() let (session, root) = try await TestSupport.makeSession( + networkPolicy: .unrestricted, permissionHandler: { request in await probe.record(request) return .allowForSession @@ -1617,6 +1620,7 @@ struct SessionIntegrationTests { func curlPermissionHandlerIsSkippedForNonHTTPURLs() async throws { let probe = PermissionProbe() let (session, root) = try await TestSupport.makeSession( + networkPolicy: .unrestricted, permissionHandler: { request in await probe.record(request) return .deny(message: "network access denied") @@ -1635,7 +1639,10 @@ struct SessionIntegrationTests { @Test("curl network policy can deny private ranges") func curlNetworkPolicyCanDenyPrivateRanges() async throws { let (session, root) = try await TestSupport.makeSession( - networkPolicy: NetworkPolicy(denyPrivateRanges: true) + networkPolicy: NetworkPolicy( + allowsHTTPRequests: true, + denyPrivateRanges: true + ) ) defer { TestSupport.removeDirectory(root) } @@ -1648,6 +1655,7 @@ struct SessionIntegrationTests { func curlNetworkPolicyCanDenyURLsOutsideAllowlist() async throws { let (session, root) = try await TestSupport.makeSession( networkPolicy: NetworkPolicy( + allowsHTTPRequests: true, allowedURLPrefixes: ["https://api.example.com/"] ) ) @@ -1658,6 +1666,76 @@ struct SessionIntegrationTests { #expect(result.stderrString.contains("not in the network allowlist")) } + @Test("curl blocks outbound http by default") + func curlBlocksOutboundHTTPByDefault() async throws { + let (session, root) = try await TestSupport.makeSession() + defer { TestSupport.removeDirectory(root) } + + let result = await session.run("curl https://example.com") + #expect(result.exitCode == 1) + #expect(result.stderrString.contains("outbound HTTP(S) access is disabled")) + } + + @Test("curl allowlist matches path boundaries instead of raw prefixes") + func curlAllowlistMatchesPathBoundariesInsteadOfRawPrefixes() async throws { + let (session, root) = try await TestSupport.makeSession( + networkPolicy: NetworkPolicy( + allowsHTTPRequests: true, + allowedURLPrefixes: ["https://api.example.com/v1"] + ) + ) + defer { TestSupport.removeDirectory(root) } + + let result = await session.run("curl https://api.example.com/v10/status") + #expect(result.exitCode == 1) + #expect(result.stderrString.contains("not in the network allowlist")) + } + + @Test("execution limits cap command count") + func executionLimitsCapCommandCount() async throws { + let (session, root) = try await TestSupport.makeSession() + defer { TestSupport.removeDirectory(root) } + + let result = await session.run( + "echo one; echo two", + options: RunOptions( + executionLimits: ExecutionLimits(maxCommandCount: 1) + ) + ) + #expect(result.exitCode == 2) + #expect(result.stderrString.contains("maximum command count")) + } + + @Test("execution limits cap loop iterations") + func executionLimitsCapLoopIterations() async throws { + let (session, root) = try await TestSupport.makeSession() + defer { TestSupport.removeDirectory(root) } + + let result = await session.run( + "while true; do echo tick; done", + options: RunOptions( + executionLimits: ExecutionLimits(maxLoopIterations: 3) + ) + ) + #expect(result.exitCode == 2) + #expect(result.stderrString.contains("while: exceeded max iterations")) + } + + @Test("execution can be cancelled with run option") + func executionCanBeCancelledWithRunOption() async throws { + let (session, root) = try await TestSupport.makeSession() + defer { TestSupport.removeDirectory(root) } + + let result = await session.run( + "while true; do echo tick; done", + options: RunOptions( + cancellationCheck: { true } + ) + ) + #expect(result.exitCode == 130) + #expect(result.stderrString.contains("execution cancelled")) + } + @Test("html-to-markdown command parity chunk") func htmlToMarkdownCommandParityChunk() async throws { let (session, root) = try await TestSupport.makeSession() diff --git a/Tests/BashTests/TestSupport.swift b/Tests/BashTests/TestSupport.swift index ee756f7..eca9296 100644 --- a/Tests/BashTests/TestSupport.swift +++ b/Tests/BashTests/TestSupport.swift @@ -13,7 +13,7 @@ enum TestSupport { filesystem: (any ShellFilesystem)? = nil, layout: SessionLayout = .unixLike, enableGlobbing: Bool = true, - networkPolicy: NetworkPolicy = .unrestricted, + networkPolicy: NetworkPolicy = .disabled, permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? = nil ) async throws -> (session: BashSession, root: URL) { let root = try makeTempDirectory() @@ -24,6 +24,7 @@ enum TestSupport { enableGlobbing: enableGlobbing, maxHistory: 1_000, networkPolicy: networkPolicy, + executionLimits: .default, permissionHandler: permissionHandler ) diff --git a/docs/command-parity-gaps.md b/docs/command-parity-gaps.md index 1edeed5..d64a89a 100644 --- a/docs/command-parity-gaps.md +++ b/docs/command-parity-gaps.md @@ -7,5 +7,5 @@ This document tracks major command parity gaps relative to `just-bash` and shell | Job control (`&`, `$!`, `jobs`, `fg`, `wait`, `ps`, `kill`) | Background execution, pseudo-PID tracking, process listing, and signal-style termination are supported for in-process commands with buffered stdout/stderr handoff. | Medium | No stopped-job state transitions (`bg`, `disown`, `SIGTSTP`/`SIGCONT`) and no true host-process/TTY semantics. | `Tests/BashTests/ParserAndFilesystemTests.swift`, `Tests/BashTests/SessionIntegrationTests.swift` | | Shell language (`$(...)`, `for`, functions) | Command substitution, here-documents via `<<` / `<<-`, unquoted heredoc expansion for the shell’s supported `$VAR` / `${...}` / `$((...))` / `$(...)` features, `if/elif/else`, `while`, `until`, `case`, `for ... in ...`, C-style `for ((...))`, function keyword form (`function name {}`), `local` scoping, direct function positional params (`$1`, `$@`, `$#`), and richer `$((...))` arithmetic operators are supported. | Medium | Still not a full shell grammar (no `select`, no nested/compound parser parity, no backtick command substitution or full bash heredoc escape semantics, no full bash function/parameter-expansion surface, and no full arithmetic-assignment grammar). | `Tests/BashTests/ParserAndFilesystemTests.swift`, `Tests/BashTests/SessionIntegrationTests.swift` | | `head` / `tail` | Line-count shorthand forms such as `head -100`, `tail -100`, `tail +100`, and attached short-option values like `-n100` are supported alongside the standard `-n` form. | Low | Still lacks full GNU signed-count parity such as interpreting negative `head -n` counts as "all but the last N" or `tail -c +N` byte-from-start semantics. | `Tests/BashTests/SessionIntegrationTests.swift`, `Tests/BashTests/CommandCoverageTests.swift` | -| `curl` / `wget` | In-process `curl`/`wget` emulation supports `data:`, jailed `file:`, and HTTP(S) fetches, plus built-in `NetworkPolicy` controls (`denyPrivateRanges`, host allowlists, URL-prefix allowlists) and an optional host permission callback with per-session exact-request grants. | Medium | No recursive `wget` modes, robots handling, retry/progress parity, or full auth/flag compatibility. | `Tests/BashTests/SessionIntegrationTests.swift`, `Tests/BashTests/CommandCoverageTests.swift` | +| `curl` / `wget` | In-process `curl`/`wget` emulation supports `data:`, jailed `file:`, and opt-in HTTP(S) fetches, plus built-in `NetworkPolicy` controls (default-off HTTP(S), `denyPrivateRanges`, host allowlists, exact URL-prefix/path-boundary allowlists) and an optional host permission callback with per-session exact-request grants. | Medium | No recursive `wget` modes, robots handling, retry/progress parity, or full auth/flag compatibility. | `Tests/BashTests/SessionIntegrationTests.swift`, `Tests/BashTests/CommandCoverageTests.swift` | | `python3` / `python` | Embedded CPython with strict shell-filesystem shims; supports `-c`, `-m`, script file/stdin execution, core stdlib + filesystem interoperability, and reuses the session network policy/permission path for socket connections. | Medium | Broader CLI flag parity, full stdlib/native-extension parity, packaging (`pip`) support, richer compatibility with process APIs (intentionally blocked in strict mode), and deeper coverage for higher-level networking libraries. | `Tests/BashPythonTests/Python3CommandTests.swift`, `Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift` | From 9c2484f1de1519d87a0440222f2086b23a2957d9 Mon Sep 17 00:00:00 2001 From: Zac White Date: Thu, 19 Mar 2026 23:23:48 -0700 Subject: [PATCH 06/14] Added session timeout --- README.md | 5 +- Sources/Bash/BashSession.swift | 69 +++++++- Sources/Bash/Commands/BuiltinCommand.swift | 152 +++++++++++++++++- Sources/Bash/Commands/UtilityCommands.swift | 48 +++--- Sources/Bash/Support/ExecutionControl.swift | 62 +++++++ Sources/Bash/Support/Permissions.swift | 37 +++++ Sources/Bash/Support/Types.swift | 5 +- Tests/BashTests/SessionIntegrationTests.swift | 57 +++++++ 8 files changed, 401 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 810fe60..b81ee24 100644 --- a/README.md +++ b/README.md @@ -241,10 +241,11 @@ public struct ExecutionLimits { public var maxFunctionDepth: Int public var maxLoopIterations: Int public var maxCommandSubstitutionDepth: Int + public var maxWallClockDuration: TimeInterval? } ``` -Each `run` executes under an `ExecutionLimits` budget. Exceeding a limit stops execution with exit code `2`. If `cancellationCheck` returns `true`, or the surrounding task is cancelled, execution stops with exit code `130`. +Each `run` executes under an `ExecutionLimits` budget. Exceeding a structural limit stops execution with exit code `2`. If `maxWallClockDuration` is exceeded, execution stops with exit code `124`. If `cancellationCheck` returns `true`, or the surrounding task is cancelled, execution stops with exit code `130`. Wall-clock accounting excludes time spent waiting on host permission callbacks. ### `PermissionRequest` and `PermissionDecision` @@ -564,7 +565,7 @@ All implemented commands support `--help`. | `seq` | `-s `, `-w`, positional numeric args | | `sleep` | positional durations (`NUMBER[SUFFIX]`, suffix: `s`, `m`, `h`, `d`) | | `time` | `time ` | -| `timeout` | `timeout ` | +| `timeout` | `timeout `; uses effective elapsed time and excludes host permission callback waits | | `true` | none | | `wait` | optional job specs (`wait`, `wait %1`) | | `whoami` | none | diff --git a/Sources/Bash/BashSession.swift b/Sources/Bash/BashSession.swift index bf1448c..2da05af 100644 --- a/Sources/Bash/BashSession.swift +++ b/Sources/Bash/BashSession.swift @@ -50,7 +50,7 @@ public final actor BashSession { cancellationCheck: options.cancellationCheck ) guard usesTemporaryState else { - return await runPersistingState( + return await runWithExecutionControl( commandLine, stdin: options.stdin, executionControl: executionControl @@ -91,7 +91,7 @@ public final actor BashSession { environmentStore.merge(options.environment) { _, rhs in rhs } } - let result = await runPersistingState( + let result = await runWithExecutionControl( commandLine, stdin: options.stdin, executionControl: executionControl @@ -103,6 +103,71 @@ public final actor BashSession { return result } + private func runWithExecutionControl( + _ commandLine: String, + stdin: Data, + executionControl: ExecutionControl + ) async -> CommandResult { + guard let maxWallClockDuration = executionControl.limits.maxWallClockDuration else { + return await runPersistingState( + commandLine, + stdin: stdin, + executionControl: executionControl + ) + } + + enum Outcome { + case completed(CommandResult) + case timedOut + } + + let task = Task { + await self.runPersistingState( + commandLine, + stdin: stdin, + executionControl: executionControl + ) + } + + let outcome = await withTaskGroup(of: Outcome.self) { group in + group.addTask { + .completed(await task.value) + } + + group.addTask { + while !Task.isCancelled { + let elapsed = await executionControl.currentEffectiveElapsedTime() + if elapsed >= maxWallClockDuration { + return .timedOut + } + + let remaining = max(0.001, min(maxWallClockDuration - elapsed, 0.01)) + let sleepNanos = UInt64(remaining * 1_000_000_000) + try? await Task.sleep(nanoseconds: sleepNanos) + } + + return .timedOut + } + + let first = await group.next() ?? .timedOut + group.cancelAll() + return first + } + + switch outcome { + case let .completed(result): + return result + case .timedOut: + await executionControl.markTimedOut() + task.cancel() + return CommandResult( + stdout: Data(), + stderr: Data("execution timed out\n".utf8), + exitCode: 124 + ) + } + } + private func runPersistingState( _ commandLine: String, stdin: Data, diff --git a/Sources/Bash/Commands/BuiltinCommand.swift b/Sources/Bash/Commands/BuiltinCommand.swift index fcd6159..f10ad9f 100644 --- a/Sources/Bash/Commands/BuiltinCommand.swift +++ b/Sources/Bash/Commands/BuiltinCommand.swift @@ -1,6 +1,60 @@ import ArgumentParser import Foundation +private actor EffectiveWallClock { + private let startedAt = ProcessInfo.processInfo.systemUptime + private var pausedDuration: TimeInterval = 0 + private var pauseDepth = 0 + private var pauseStartedAt: TimeInterval? + + func beginPause() { + if pauseDepth == 0 { + pauseStartedAt = ProcessInfo.processInfo.systemUptime + } + pauseDepth += 1 + } + + func endPause() { + guard pauseDepth > 0 else { + return + } + + pauseDepth -= 1 + guard pauseDepth == 0, let pauseStartedAt else { + return + } + + pausedDuration += max(0, ProcessInfo.processInfo.systemUptime - pauseStartedAt) + self.pauseStartedAt = nil + } + + func elapsed() -> TimeInterval { + let now = ProcessInfo.processInfo.systemUptime + var effectivePausedDuration = pausedDuration + if let pauseStartedAt { + effectivePausedDuration += max(0, now - pauseStartedAt) + } + return max(0, now - startedAt - effectivePausedDuration) + } +} + +private actor PermissionPauseAuthorizer: PermissionAuthorizing { + private let base: any PermissionAuthorizing + private let clock: EffectiveWallClock + + init(base: any PermissionAuthorizing, clock: EffectiveWallClock) { + self.base = base + self.clock = clock + } + + func authorize(_ request: PermissionRequest) async -> PermissionDecision { + await clock.beginPause() + let decision = await base.authorize(request) + await clock.endPause() + return decision + } +} + public struct CommandContext: Sendable { public let commandName: String public let arguments: [String] @@ -179,7 +233,14 @@ public struct CommandContext: Sendable { public func requestPermission( _ request: PermissionRequest ) async -> PermissionDecision { - await permissionAuthorizer.authorize(request) + if let permissionAuthorizer = permissionAuthorizer as? PermissionAuthorizer { + return await permissionAuthorizer.authorize( + request, + pausing: executionControl + ) + } + + return await permissionAuthorizer.authorize(request) } public func requestNetworkPermission( @@ -211,6 +272,19 @@ public struct CommandContext: Sendable { public func runSubcommandIsolated( _ argv: [String], stdin: Data? = nil + ) async -> (result: CommandResult, currentDirectory: String, environment: [String: String]) { + await runSubcommandIsolated( + argv, + stdin: stdin, + executionControlOverride: nil + ) + } + + func runSubcommandIsolated( + _ argv: [String], + stdin: Data? = nil, + executionControlOverride: ExecutionControl?, + permissionAuthorizerOverride: (any PermissionAuthorizing)? = nil ) async -> (result: CommandResult, currentDirectory: String, environment: [String: String]) { guard let commandName = argv.first else { return (CommandResult(stdout: Data(), stderr: Data(), exitCode: 0), currentDirectory, environment) @@ -226,7 +300,9 @@ public struct CommandContext: Sendable { } let commandArgs = Array(argv.dropFirst()) - if let failure = await executionControl?.recordCommandExecution(commandName: commandName) { + let effectiveExecutionControl = executionControlOverride ?? executionControl + let effectivePermissionAuthorizer = permissionAuthorizerOverride ?? permissionAuthorizer + if let failure = await effectiveExecutionControl?.recordCommandExecution(commandName: commandName) { return ( CommandResult( stdout: Data(), @@ -253,8 +329,8 @@ public struct CommandContext: Sendable { stdin: stdin ?? self.stdin, secretTracker: secretTracker, jobControl: jobControl, - permissionAuthorizer: permissionAuthorizer, - executionControl: executionControl + permissionAuthorizer: effectivePermissionAuthorizer, + executionControl: effectiveExecutionControl ) let exitCode = await implementation.runCommand(&childContext, commandArgs) @@ -265,6 +341,74 @@ public struct CommandContext: Sendable { ) } + public func runSubcommandIsolated( + _ argv: [String], + stdin: Data? = nil, + wallClockTimeout: TimeInterval + ) async -> (result: CommandResult, currentDirectory: String, environment: [String: String]) { + let clock = EffectiveWallClock() + let wrappedPermissionAuthorizer = PermissionPauseAuthorizer( + base: permissionAuthorizer, + clock: clock + ) + + enum Outcome: Sendable { + case completed(CommandResult, String, [String: String]) + case timedOut + } + + let task = Task { + await runSubcommandIsolated( + argv, + stdin: stdin, + executionControlOverride: executionControl, + permissionAuthorizerOverride: wrappedPermissionAuthorizer + ) + } + + let outcome = await withTaskGroup(of: Outcome.self) { group in + group.addTask { + let sub = await task.value + return .completed(sub.result, sub.currentDirectory, sub.environment) + } + + group.addTask { + while !Task.isCancelled { + let elapsed = await clock.elapsed() + if elapsed >= wallClockTimeout { + return .timedOut + } + + let remaining = max(0.001, min(wallClockTimeout - elapsed, 0.01)) + let sleepNanos = UInt64(remaining * 1_000_000_000) + try? await Task.sleep(nanoseconds: sleepNanos) + } + + return .timedOut + } + + let first = await group.next() ?? .timedOut + group.cancelAll() + return first + } + + switch outcome { + case let .completed(result, currentDirectory, environment): + return (result, currentDirectory, environment) + case .timedOut: + task.cancel() + return ( + CommandResult( + stdout: Data(), + stderr: Data("timeout: command timed out\n".utf8), + exitCode: 124 + ), + currentDirectory, + environment + ) + } + } + private func resolveCommand(named commandName: String) -> AnyBuiltinCommand? { if commandName.hasPrefix("/") { return commandRegistry[PathUtils.basename(commandName)] diff --git a/Sources/Bash/Commands/UtilityCommands.swift b/Sources/Bash/Commands/UtilityCommands.swift index 8838906..ee4ef8b 100644 --- a/Sources/Bash/Commands/UtilityCommands.swift +++ b/Sources/Bash/Commands/UtilityCommands.swift @@ -706,7 +706,6 @@ struct SleepCommand: BuiltinCommand { static let overview = "Delay for a specified amount of time" static func run(context: inout CommandContext, options: Options) async -> Int32 { - _ = context guard !options.durations.isEmpty else { context.writeStderr("sleep: missing operand\n") return 1 @@ -723,8 +722,16 @@ struct SleepCommand: BuiltinCommand { let nanosDouble = max(0, totalSeconds) * 1_000_000_000 let nanos = UInt64(min(nanosDouble, Double(UInt64.max))) - try? await Task.sleep(nanoseconds: nanos) - return 0 + do { + try await Task.sleep(nanoseconds: nanos) + return 0 + } catch { + if let failure = await context.executionControl?.checkpoint() { + context.writeStderr("\(failure.message)\n") + return failure.exitCode + } + return 130 + } } private static func parseDuration(_ token: String) -> Double? { @@ -829,34 +836,25 @@ struct TimeoutCommand: BuiltinCommand { case timedOut } - let baseContext = context - let timeoutNanos = UInt64(options.seconds * 1_000_000_000) - let outcome = await withTaskGroup(of: Outcome.self) { group in - group.addTask { - let sub = await baseContext.runSubcommandIsolated(options.command, stdin: baseContext.stdin) - return .completed(sub.result, sub.currentDirectory, sub.environment) - } + let sub = await context.runSubcommandIsolated( + options.command, + stdin: context.stdin, + wallClockTimeout: options.seconds + ) - group.addTask { - try? await Task.sleep(nanoseconds: timeoutNanos) - return .timedOut - } - - let first = await group.next() ?? .timedOut - group.cancelAll() - return first - } - - switch outcome { - case let .completed(result, newDirectory, newEnvironment): + switch sub.result.exitCode { + case 124: + context.stderr.append(sub.result.stderr) + return 124 + default: + let result = sub.result + let newDirectory = sub.currentDirectory + let newEnvironment = sub.environment context.currentDirectory = newDirectory context.environment = newEnvironment context.stdout.append(result.stdout) context.stderr.append(result.stderr) return result.exitCode - case .timedOut: - context.writeStderr("timeout: command timed out\n") - return 124 } } } diff --git a/Sources/Bash/Support/ExecutionControl.swift b/Sources/Bash/Support/ExecutionControl.swift index 08f7474..4c15c6b 100644 --- a/Sources/Bash/Support/ExecutionControl.swift +++ b/Sources/Bash/Support/ExecutionControl.swift @@ -8,10 +8,15 @@ struct ExecutionFailure: Sendable { actor ExecutionControl { nonisolated let limits: ExecutionLimits private let cancellationCheck: (@Sendable () -> Bool)? + private let startedAt: TimeInterval private var commandCount = 0 private var functionDepth = 0 private var commandSubstitutionDepth = 0 + private var permissionPauseDepth = 0 + private var permissionPauseStartedAt: TimeInterval? + private var pausedDuration: TimeInterval = 0 + private var timeoutFailure: ExecutionFailure? init( limits: ExecutionLimits, @@ -19,9 +24,21 @@ actor ExecutionControl { ) { self.limits = limits self.cancellationCheck = cancellationCheck + self.startedAt = Self.monotonicNow() } func checkpoint() -> ExecutionFailure? { + if let timeoutFailure { + return timeoutFailure + } + + if let maxWallClockDuration = limits.maxWallClockDuration, + effectiveElapsedTime() >= maxWallClockDuration { + let failure = ExecutionFailure(exitCode: 124, message: "execution timed out") + timeoutFailure = failure + return failure + } + if Task.isCancelled || cancellationCheck?() == true { return ExecutionFailure(exitCode: 130, message: "execution cancelled") } @@ -96,4 +113,49 @@ actor ExecutionControl { func popCommandSubstitution() { commandSubstitutionDepth = max(0, commandSubstitutionDepth - 1) } + + func currentEffectiveElapsedTime() -> TimeInterval { + effectiveElapsedTime() + } + + func beginPermissionPause() { + if permissionPauseDepth == 0 { + permissionPauseStartedAt = Self.monotonicNow() + } + permissionPauseDepth += 1 + } + + func endPermissionPause() { + guard permissionPauseDepth > 0 else { + return + } + + permissionPauseDepth -= 1 + guard permissionPauseDepth == 0, + let permissionPauseStartedAt + else { + return + } + + pausedDuration += max(0, Self.monotonicNow() - permissionPauseStartedAt) + self.permissionPauseStartedAt = nil + } + + func markTimedOut(message: String = "execution timed out") { + timeoutFailure = ExecutionFailure(exitCode: 124, message: message) + } + + private func effectiveElapsedTime() -> TimeInterval { + let now = Self.monotonicNow() + var effectivePausedDuration = pausedDuration + if let permissionPauseStartedAt { + effectivePausedDuration += max(0, now - permissionPauseStartedAt) + } + + return max(0, now - startedAt - effectivePausedDuration) + } + + nonisolated private static func monotonicNow() -> TimeInterval { + ProcessInfo.processInfo.systemUptime + } } diff --git a/Sources/Bash/Support/Permissions.swift b/Sources/Bash/Support/Permissions.swift index 8c48968..491e796 100644 --- a/Sources/Bash/Support/Permissions.swift +++ b/Sources/Bash/Support/Permissions.swift @@ -105,6 +105,43 @@ actor PermissionAuthorizer: PermissionAuthorizing { return decision } + + func authorize( + _ request: PermissionRequest, + pausing executionControl: ExecutionControl? + ) async -> PermissionDecision { + if let denial = PermissionPolicyEvaluator.denialMessage( + for: request, + networkPolicy: networkPolicy + ) { + return .deny(message: denial) + } + + if sessionAllows.contains(request) { + return .allow + } + + guard let handler else { + return .allow + } + + if let executionControl { + await executionControl.beginPermissionPause() + } + + let decision = await handler(request) + + if let executionControl { + await executionControl.endPermissionPause() + } + + if case .allowForSession = decision { + sessionAllows.insert(request) + return .allow + } + + return decision + } } private enum PermissionPolicyEvaluator { diff --git a/Sources/Bash/Support/Types.swift b/Sources/Bash/Support/Types.swift index 0716430..f51763a 100644 --- a/Sources/Bash/Support/Types.swift +++ b/Sources/Bash/Support/Types.swift @@ -52,17 +52,20 @@ public struct ExecutionLimits: Sendable { public var maxFunctionDepth: Int public var maxLoopIterations: Int public var maxCommandSubstitutionDepth: Int + public var maxWallClockDuration: TimeInterval? public init( maxCommandCount: Int = 10_000, maxFunctionDepth: Int = 100, maxLoopIterations: Int = 10_000, - maxCommandSubstitutionDepth: Int = 32 + maxCommandSubstitutionDepth: Int = 32, + maxWallClockDuration: TimeInterval? = nil ) { self.maxCommandCount = maxCommandCount self.maxFunctionDepth = maxFunctionDepth self.maxLoopIterations = maxLoopIterations self.maxCommandSubstitutionDepth = maxCommandSubstitutionDepth + self.maxWallClockDuration = maxWallClockDuration } } diff --git a/Tests/BashTests/SessionIntegrationTests.swift b/Tests/BashTests/SessionIntegrationTests.swift index ac93684..e142cb8 100644 --- a/Tests/BashTests/SessionIntegrationTests.swift +++ b/Tests/BashTests/SessionIntegrationTests.swift @@ -1830,3 +1830,60 @@ struct SessionIntegrationTests { #expect(empty.stdoutString.isEmpty) } } + +@Suite("Session Integration Timeouts", .serialized) +struct SessionIntegrationTimeoutTests { + @Test("execution limits cap wall clock time") + func executionLimitsCapWallClockTime() async throws { + let (session, root) = try await TestSupport.makeSession() + defer { TestSupport.removeDirectory(root) } + + let result = await session.run( + "sleep 0.2", + options: RunOptions( + executionLimits: ExecutionLimits(maxWallClockDuration: 0.01) + ) + ) + #expect(result.exitCode == 124) + #expect(result.stderrString.contains("execution timed out")) + } + + @Test("timeout excludes permission wait time") + func timeoutExcludesPermissionWaitTime() async throws { + let (session, root) = try await TestSupport.makeSession( + networkPolicy: .unrestricted, + permissionHandler: { _ in + try? await Task.sleep(nanoseconds: 1_000_000_000) + return .deny(message: "blocked after approval wait") + } + ) + defer { TestSupport.removeDirectory(root) } + + let result = await session.run("timeout 0.5 curl https://example.com") + #expect(result.exitCode == 1) + #expect(result.stderrString.contains("blocked after approval wait")) + #expect(!result.stderrString.contains("timed out")) + } + + @Test("wall clock limits exclude permission wait time") + func wallClockLimitsExcludePermissionWaitTime() async throws { + let (session, root) = try await TestSupport.makeSession( + networkPolicy: .unrestricted, + permissionHandler: { _ in + try? await Task.sleep(nanoseconds: 1_000_000_000) + return .deny(message: "blocked after approval wait") + } + ) + defer { TestSupport.removeDirectory(root) } + + let result = await session.run( + "curl https://example.com", + options: RunOptions( + executionLimits: ExecutionLimits(maxWallClockDuration: 0.5) + ) + ) + #expect(result.exitCode == 1) + #expect(result.stderrString.contains("blocked after approval wait")) + #expect(!result.stderrString.contains("execution timed out")) + } +} From 23fdfcb7837bfff771858499aa7086815f605fa2 Mon Sep 17 00:00:00 2001 From: Zac White Date: Thu, 19 Mar 2026 23:31:38 -0700 Subject: [PATCH 07/14] Added section to README on beta status --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index b81ee24..061892a 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Repository: [github.com/velos/Bash.swift](https://github.com/velos/Bash.swift) You create a `BashSession`, run shell command strings, and get structured `stdout` / `stderr` / `exitCode` results. Commands mutate a real directory on disk through a sandboxed, root-jail filesystem abstraction. +Like `just-bash`, `Bash.swift` should be treated as beta software and used at your own risk. The library is practical for app and agent workflows, but it is still evolving and should not be treated as a hardened isolation boundary or a drop-in replacement for a real system shell. + ## Development Process Development of `Bash.swift` was approached very similarly to [just-bash](https://github.com/vercel-labs/just-bash). All output was with GPT-5.3-Codex Extra High thinking, initiated by an interactively built plan, executed by the model after the plan was finalized. From 6fcfba4798a1f3c70ad9971d6598b11a637e2636 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 22 Mar 2026 20:56:43 -0700 Subject: [PATCH 08/14] Started extracting Workspace --- AGENTS.md | 25 +- Package.resolved | 11 +- Package.swift | 2 + README.md | 79 ++- Sources/Bash/BashSession.swift | 6 +- Sources/Bash/Commands/BuiltinCommand.swift | 21 +- .../Bash/Commands/NavigationCommands.swift | 1 + Sources/Bash/Commands/Text/DiffCommand.swift | 2 +- Sources/Bash/Core/PathUtils.swift | 105 ---- Sources/Bash/Core/ShellExecutor.swift | 43 +- Sources/Bash/FS/BookmarkStore.swift | 7 - Sources/Bash/FS/InMemoryFilesystem.swift | 520 ------------------ Sources/Bash/FS/MountableFilesystem.swift | 359 ------------ Sources/Bash/FS/OverlayFilesystem.swift | 172 ------ Sources/Bash/FS/ReadWriteFilesystem.swift | 309 ----------- Sources/Bash/FS/SandboxFilesystem.swift | 117 ---- .../Bash/FS/SecurityScopedFilesystem.swift | 202 ------- .../FS/SessionConfigurableFilesystem.swift | 5 - Sources/Bash/FS/ShellFilesystem.swift | 22 - .../Bash/FS/UserDefaultsBookmarkStore.swift | 27 - .../Support/PermissionedShellFilesystem.swift | 331 +++++++++++ Sources/Bash/Support/Permissions.swift | 68 +++ Sources/Bash/Support/Types.swift | 36 +- Sources/Bash/WorkspaceCompat.swift | 5 + .../CPythonRuntimeIntegrationTests.swift | 2 + Tests/BashTests/FilesystemOptionsTests.swift | 54 +- .../BashTests/ParserAndFilesystemTests.swift | 1 - Tests/BashTests/SessionIntegrationTests.swift | 237 ++++++++ 28 files changed, 826 insertions(+), 1943 deletions(-) delete mode 100644 Sources/Bash/Core/PathUtils.swift delete mode 100644 Sources/Bash/FS/BookmarkStore.swift delete mode 100644 Sources/Bash/FS/InMemoryFilesystem.swift delete mode 100644 Sources/Bash/FS/MountableFilesystem.swift delete mode 100644 Sources/Bash/FS/OverlayFilesystem.swift delete mode 100644 Sources/Bash/FS/ReadWriteFilesystem.swift delete mode 100644 Sources/Bash/FS/SandboxFilesystem.swift delete mode 100644 Sources/Bash/FS/SecurityScopedFilesystem.swift delete mode 100644 Sources/Bash/FS/SessionConfigurableFilesystem.swift delete mode 100644 Sources/Bash/FS/ShellFilesystem.swift delete mode 100644 Sources/Bash/FS/UserDefaultsBookmarkStore.swift create mode 100644 Sources/Bash/Support/PermissionedShellFilesystem.swift create mode 100644 Sources/Bash/WorkspaceCompat.swift diff --git a/AGENTS.md b/AGENTS.md index 95721a6..48b21f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,27 +138,28 @@ Optional module commands: ## Filesystem Architecture Filesystem protocol: -- `Sources/Bash/FS/ShellFilesystem.swift` - -Rootless-session protocol: -- `Sources/Bash/FS/SessionConfigurableFilesystem.swift` +- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/FS/WorkspaceFilesystem.swift` Implementations: -- `Sources/Bash/FS/ReadWriteFilesystem.swift` +- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/FS/ReadWriteFilesystem.swift` - Real disk I/O with jail to configured root. -- `Sources/Bash/FS/InMemoryFilesystem.swift` +- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/FS/InMemoryFilesystem.swift` - Pure in-memory tree. -- `Sources/Bash/FS/SandboxFilesystem.swift` +- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/FS/OverlayFilesystem.swift` + - Copy-on-write snapshot of a configured disk root, with explicit `reload()` support. +- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/FS/MountableFilesystem.swift` + - Composes multiple filesystem backends under virtual mount points. +- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/FS/SandboxFilesystem.swift` - Root chooser (`documents`, `caches`, `temporary`, app group, custom URL), delegates to read-write backing. -- `Sources/Bash/FS/SecurityScopedFilesystem.swift` +- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/FS/SecurityScopedFilesystem.swift` - Security-scoped URL/bookmark-backed root, optional read-only mode, runtime unsupported on tvOS/watchOS. Bookmark persistence: -- `Sources/Bash/FS/BookmarkStore.swift` -- `Sources/Bash/FS/UserDefaultsBookmarkStore.swift` +- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/FS/BookmarkStore.swift` +- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/FS/UserDefaultsBookmarkStore.swift` Path + jail utilities: -- `Sources/Bash/Core/PathUtils.swift` +- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/Core/PathUtils.swift` ## Parser + Executor Source Map @@ -221,7 +222,7 @@ When adding or changing a command: When adding filesystem implementations: 1. Conform to `ShellFilesystem`. -2. Conform to `SessionConfigurableFilesystem` if rootless `BashSession(options:)` should be supported. +2. Ensure the filesystem is ready to use before passing it into `BashSession(options:)`; do not rely on hidden session setup. 3. Keep path normalization and jail guarantees explicit and tested. 4. Add platform-conditional tests in `FilesystemOptionsTests`. diff --git a/Package.resolved b/Package.resolved index 561d5ba..10bd8c7 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fbb858abd7fcfd0fd009041b262ab4f8513932ad68db646cf230ec1aa94451e6", + "originHash" : "ec694d34f76f14a85744579ce0dd200e5e16b3548f722460ba3542b0fa505ada", "pins" : [ { "identity" : "swift-argument-parser", @@ -10,6 +10,15 @@ "version" : "1.7.0" } }, + { + "identity" : "workspace", + "kind" : "remoteSourceControl", + "location" : "https://github.com/velos/Workspace.git", + "state" : { + "revision" : "752bb5ef27f08ce6d97e6a3c2537a7358ae2635e", + "version" : "0.1.0" + } + }, { "identity" : "yams", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index ced06ca..0329002 100644 --- a/Package.swift +++ b/Package.swift @@ -33,6 +33,7 @@ let package = Package( ), ], dependencies: [ + .package(url: "https://github.com/velos/Workspace.git", from: "0.1.0"), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), .package(url: "https://github.com/jpsim/Yams", from: "5.1.3"), ], @@ -50,6 +51,7 @@ let package = Package( .target( name: "Bash", dependencies: [ + .product(name: "Workspace", package: "Workspace"), .product(name: "ArgumentParser", package: "swift-argument-parser"), ] ), diff --git a/README.md b/README.md index 061892a..7d886f9 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Development of `Bash.swift` was approached very similarly to [just-bash](https:/ - [Installation](#installation) - [Platform Support](#platform-support) - [Quick Start](#quick-start) +- [Workspace Modules](#workspace-modules) - [Public API](#public-api) - [How It Works](#how-it-works) - [Security](#security) @@ -52,6 +53,8 @@ Development of `Bash.swift` was approached very similarly to [just-bash](https:/ ] ``` +The reusable shell-agnostic workspace layer now lives in a separate `Workspace` package/repository. `Bash.swift` depends on that package, but it is no longer shipped as part of this repo. + `BashSQLite`, `BashPython`, `BashGit`, and `BashSecrets` are optional products. Add them only if needed: ```swift @@ -189,6 +192,35 @@ Policies: - `.resolveAndRedact`: resolve refs (where supported) and redact/replace secrets in output - `.strict`: like `.resolveAndRedact`, plus blocks high-risk flows like `secrets get --reveal` +## Workspace Modules + +`Bash` now sits on top of reusable workspace primitives provided by a separate `Workspace` package: + +- `Workspace`: a typed agent-facing API plus the shell-agnostic filesystem abstractions, jailed/rooted filesystem implementations, overlays, mounts, bookmarks, path helpers, and filesystem permission wrappers it is built on. + +Import `Workspace` from that package directly when you want workspace tooling without shell parsing or command execution: + +```swift +import Workspace + +let filesystem = PermissionedWorkspaceFilesystem( + base: try OverlayFilesystem(rootDirectory: workspaceRoot), + authorizer: WorkspacePermissionAuthorizer { request in + switch request.operation { + case .readFile, .listDirectory, .stat: + return .allowForSession + default: + return .deny(message: "write access denied") + } + } +) + +let workspace = Workspace(filesystem: filesystem) +let tree = try await workspace.summarizeTree("/workspace", maxDepth: 2) +``` + +`replaceInFiles` and `applyEdits` support dry runs plus best-effort rollback on failure. That rollback is logical state restoration within the provided filesystem, not an OS-level atomic transaction and not crash-safe across processes. + ## Public API ### `BashSession` @@ -255,6 +287,7 @@ Each `run` executes under an `ExecutionLimits` budget. Exceeding a structural li public struct PermissionRequest { public enum Kind { case network(NetworkPermissionRequest) + case filesystem(FilesystemPermissionRequest) } public var command: String @@ -266,6 +299,33 @@ public struct NetworkPermissionRequest { public var method: String } +public enum FilesystemPermissionOperation: String { + case stat + case listDirectory + case readFile + case writeFile + case createDirectory + case remove + case move + case copy + case createSymlink + case createHardLink + case readSymlink + case setPermissions + case resolveRealPath + case exists + case glob +} + +public struct FilesystemPermissionRequest { + public var operation: FilesystemPermissionOperation + public var path: String? + public var sourcePath: String? + public var destinationPath: String? + public var append: Bool + public var recursive: Bool +} + public enum PermissionDecision { case allow case allowForSession @@ -320,7 +380,7 @@ Defaults: - `secretResolver`: `nil` - `secretOutputRedactor`: `DefaultSecretOutputRedactor()` -Use `networkPolicy` for built-in outbound rules such as default-off HTTP(S), private-range blocking, and allowlists. Use `executionLimits` to bound shell work at the session level. Use `permissionHandler` when the host app or agent needs explicit control over outbound permissions after the built-in policy passes. Returning `.allow` grants the current request once, `.allowForSession` caches an exact-match request for the life of that `BashSession`, and `.deny(message:)` blocks it with a user-visible error. If you want broader or persistent memory across sessions, keep that policy in the host and decide what to return from the callback. +Use `networkPolicy` for built-in outbound rules such as default-off HTTP(S), private-range blocking, and allowlists. Use `executionLimits` to bound shell work at the session level. Use `permissionHandler` when the host app or agent needs explicit control over filesystem and outbound network access after the built-in policy passes. Returning `.allow` grants the current request once, `.allowForSession` caches an exact-match request for the life of that `BashSession`, and `.deny(message:)` blocks it with a user-visible error. If you want broader or persistent memory across sessions, keep that policy in the host and decide what to return from the callback. Example built-in policy plus callback: @@ -339,6 +399,13 @@ let options = SessionOptions( return .allowForSession } return .deny(message: "network access denied") + case let .filesystem(filesystem): + switch filesystem.operation { + case .readFile, .listDirectory, .stat: + return .allowForSession + default: + return .deny(message: "filesystem access denied") + } } } ) @@ -352,6 +419,8 @@ Available filesystem implementations: - `SandboxFilesystem`: resolves app container-style roots (`documents`, `caches`, `temporary`, app group, custom URL). - `SecurityScopedFilesystem`: URL/bookmark-backed filesystem for security-scoped access. +For non-shell agent tooling, `Workspace` exposes the same filesystem stack under shell-agnostic names like `WorkspaceFilesystem`, `WorkspacePath`, `WorkspaceError`, and `PermissionedWorkspaceFilesystem`, along with the higher-level `Workspace` actor for typed tree traversal and batch editing helpers. A single `Workspace` can also sit on top of a `MountableFilesystem`, so isolated roots plus a shared `/memory` mount are already possible through the current interfaces. + ### `SessionLayout` - `.unixLike` (default): creates `/home/user`, `/bin`, `/usr/bin`, `/tmp`; starts in `/home/user` @@ -374,6 +443,7 @@ Execution pipeline: Current hardening layers include: - Root-jail filesystem implementations plus null-byte path rejection. +- Reusable workspace-level permission wrappers (`PermissionedWorkspaceFilesystem`) that can gate reads, writes, moves, copies, symlinks, and metadata operations before they hit the underlying filesystem. - Optional `NetworkPolicy` rules with default-off HTTP(S), `denyPrivateRanges`, host allowlists, URL-prefix allowlists, and the host `permissionHandler`. - Built-in execution budgets for command count, loop iterations, function depth, and command substitution depth, plus host-driven cancellation. - Strict `BashPython` shims that block process/FFI escape APIs like `subprocess`, `ctypes`, and `os.system`. @@ -429,10 +499,12 @@ let inMemory = SessionOptions(filesystem: InMemoryFilesystem()) let session = try await BashSession(options: inMemory) ``` -`BashSession.init(options:)` works with filesystems that can self-configure for a session (`SessionConfigurableFilesystem`), such as `InMemoryFilesystem`, `OverlayFilesystem`, `MountableFilesystem`, `SandboxFilesystem`, and `SecurityScopedFilesystem`. +`BashSession.init(options:)` uses the filesystem exactly as provided. Pass a ready-to-use filesystem instance. `InMemoryFilesystem` works immediately; root-backed filesystems should be constructed or configured with their root before being passed in. You can provide a custom filesystem by implementing `ShellFilesystem`. +If you do not need shell semantics, use `WorkspaceFilesystem` and the higher-level `Workspace` actor directly. The underlying jail, overlay, mount, bookmark, and permission concepts are shared; the shell layer is optional. + ### Filesystem Platform Matrix | Filesystem | macOS | iOS | Catalyst | tvOS/watchOS | @@ -451,7 +523,6 @@ let store = UserDefaultsBookmarkStore() // Create from a URL chosen by your app's document flow. let fs = try SecurityScopedFilesystem(url: pickedURL, mode: .readWrite) -try fs.configureForSession() try await fs.saveBookmark(id: "workspace", store: store) // Restore on a later app launch. @@ -583,7 +654,7 @@ All implemented commands support `--help`. When `SessionOptions.secretPolicy` is `.resolveAndRedact` or `.strict`, `curl` resolves `secretref:v1:...` tokens in headers/body arguments and output redaction replaces resolved values with their reference tokens. When `SessionOptions.networkPolicy` is set, `curl`/`wget`, `git clone` remotes, and `BashPython` socket connections enforce the same built-in default-off HTTP(S), allowlist, and private-range rules. -When `SessionOptions.permissionHandler` is set, `curl` and `wget` ask it before outbound HTTP(S) requests, `git clone` asks it before remote clones, and `BashPython` asks it before socket connections. `data:` and jailed `file:` URLs do not trigger network checks. +When `SessionOptions.permissionHandler` is set, shell filesystem operations and redirections ask it before reading or mutating files, `curl` and `wget` ask it before outbound HTTP(S) requests, `git clone` asks it before remote clones, and `BashPython` asks it before socket connections. Permission callback wait time is excluded from both `timeout` and run-level wall-clock budgets. `data:` and jailed `file:` URLs do not trigger network checks. ## Command Behaviors and Notes diff --git a/Sources/Bash/BashSession.swift b/Sources/Bash/BashSession.swift index 2da05af..f125101 100644 --- a/Sources/Bash/BashSession.swift +++ b/Sources/Bash/BashSession.swift @@ -1,4 +1,5 @@ import Foundation +import Workspace public final actor BashSession { let filesystemStore: any ShellFilesystem @@ -29,11 +30,6 @@ public final actor BashSession { public init(options: SessionOptions = .init()) async throws { let filesystem = options.filesystem - guard let configurable = filesystem as? any SessionConfigurableFilesystem else { - throw ShellError.unsupported("filesystem requires rootDirectory initializer") - } - - try configurable.configureForSession() try await self.init(options: options, configuredFilesystem: filesystem) } diff --git a/Sources/Bash/Commands/BuiltinCommand.swift b/Sources/Bash/Commands/BuiltinCommand.swift index f10ad9f..95a3c86 100644 --- a/Sources/Bash/Commands/BuiltinCommand.swift +++ b/Sources/Bash/Commands/BuiltinCommand.swift @@ -233,14 +233,11 @@ public struct CommandContext: Sendable { public func requestPermission( _ request: PermissionRequest ) async -> PermissionDecision { - if let permissionAuthorizer = permissionAuthorizer as? PermissionAuthorizer { - return await permissionAuthorizer.authorize( - request, - pausing: executionControl - ) - } - - return await permissionAuthorizer.authorize(request) + await authorizePermissionRequest( + request, + using: permissionAuthorizer, + pausing: executionControl + ) } public func requestNetworkPermission( @@ -302,6 +299,12 @@ public struct CommandContext: Sendable { let commandArgs = Array(argv.dropFirst()) let effectiveExecutionControl = executionControlOverride ?? executionControl let effectivePermissionAuthorizer = permissionAuthorizerOverride ?? permissionAuthorizer + let childFilesystem = PermissionedShellFilesystem( + base: PermissionedShellFilesystem.unwrap(filesystem), + commandName: commandName, + permissionAuthorizer: effectivePermissionAuthorizer, + executionControl: effectiveExecutionControl + ) if let failure = await effectiveExecutionControl?.recordCommandExecution(commandName: commandName) { return ( CommandResult( @@ -317,7 +320,7 @@ public struct CommandContext: Sendable { var childContext = CommandContext( commandName: commandName, arguments: commandArgs, - filesystem: filesystem, + filesystem: childFilesystem, enableGlobbing: enableGlobbing, secretPolicy: secretPolicy, secretResolver: secretResolver, diff --git a/Sources/Bash/Commands/NavigationCommands.swift b/Sources/Bash/Commands/NavigationCommands.swift index 28b6542..6359008 100644 --- a/Sources/Bash/Commands/NavigationCommands.swift +++ b/Sources/Bash/Commands/NavigationCommands.swift @@ -1,5 +1,6 @@ import ArgumentParser import Foundation +import Workspace struct BasenameCommand: BuiltinCommand { struct Options: ParsableArguments { diff --git a/Sources/Bash/Commands/Text/DiffCommand.swift b/Sources/Bash/Commands/Text/DiffCommand.swift index 7751af1..eb7b98e 100644 --- a/Sources/Bash/Commands/Text/DiffCommand.swift +++ b/Sources/Bash/Commands/Text/DiffCommand.swift @@ -1,5 +1,6 @@ import ArgumentParser import Foundation +import Workspace struct DiffCommand: BuiltinCommand { struct Options: ParsableArguments { @@ -198,4 +199,3 @@ struct DiffCommand: BuiltinCommand { return lines } } - diff --git a/Sources/Bash/Core/PathUtils.swift b/Sources/Bash/Core/PathUtils.swift deleted file mode 100644 index 7465828..0000000 --- a/Sources/Bash/Core/PathUtils.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Foundation - -enum PathUtils { - static func validate(_ path: String) throws { - if path.contains("\u{0}") { - throw ShellError.invalidPath(path) - } - } - - static func normalize(path: String, currentDirectory: String) -> String { - if path.isEmpty { - return currentDirectory - } - - let base: [String] - if path.hasPrefix("/") { - base = [] - } else { - base = splitComponents(currentDirectory) - } - - var parts = base - for piece in path.split(separator: "/", omittingEmptySubsequences: true) { - switch piece { - case ".": - continue - case "..": - if !parts.isEmpty { - parts.removeLast() - } - default: - parts.append(String(piece)) - } - } - - return "/" + parts.joined(separator: "/") - } - - static func splitComponents(_ absolutePath: String) -> [String] { - absolutePath.split(separator: "/", omittingEmptySubsequences: true).map(String.init) - } - - static func basename(_ path: String) -> String { - let normalized = path == "/" ? "/" : path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - if normalized == "/" || normalized.isEmpty { - return "/" - } - return normalized.split(separator: "/").last.map(String.init) ?? "/" - } - - static func dirname(_ path: String) -> String { - let normalized = normalize(path: path, currentDirectory: "/") - if normalized == "/" { - return "/" - } - - var parts = splitComponents(normalized) - _ = parts.popLast() - if parts.isEmpty { - return "/" - } - return "/" + parts.joined(separator: "/") - } - - static func join(_ lhs: String, _ rhs: String) -> String { - if rhs.hasPrefix("/") { - return normalize(path: rhs, currentDirectory: "/") - } - - let separator = lhs.hasSuffix("/") ? "" : "/" - return normalize(path: lhs + separator + rhs, currentDirectory: "/") - } - - static func containsGlob(_ token: String) -> Bool { - token.contains("*") || token.contains("?") || token.contains("[") - } - - static func globToRegex(_ pattern: String) -> String { - var regex = "^" - var index = pattern.startIndex - - while index < pattern.endIndex { - let char = pattern[index] - if char == "*" { - regex += ".*" - } else if char == "?" { - regex += "." - } else if char == "[" { - if let closeIndex = pattern[index...].firstIndex(of: "]") { - let range = pattern.index(after: index).. Data? - func deleteBookmark(for id: String) async throws -} diff --git a/Sources/Bash/FS/InMemoryFilesystem.swift b/Sources/Bash/FS/InMemoryFilesystem.swift deleted file mode 100644 index 42d338e..0000000 --- a/Sources/Bash/FS/InMemoryFilesystem.swift +++ /dev/null @@ -1,520 +0,0 @@ -import Foundation - -public final class InMemoryFilesystem: SessionConfigurableFilesystem, @unchecked Sendable { - private final class Node { - enum Kind { - case file(Data) - case directory([String: Node]) - case symlink(String) - } - - var kind: Kind - var permissions: Int - var modificationDate: Date - - init(kind: Kind, permissions: Int, modificationDate: Date = Date()) { - self.kind = kind - self.permissions = permissions - self.modificationDate = modificationDate - } - - var isDirectory: Bool { - if case .directory = kind { - return true - } - return false - } - - var isSymbolicLink: Bool { - if case .symlink = kind { - return true - } - return false - } - - var size: UInt64 { - switch kind { - case let .file(data): - return UInt64(data.count) - case let .symlink(target): - return UInt64(target.utf8.count) - case .directory: - return 0 - } - } - - func clone() -> Node { - switch kind { - case let .file(data): - return Node(kind: .file(data), permissions: permissions, modificationDate: modificationDate) - case let .symlink(target): - return Node(kind: .symlink(target), permissions: permissions, modificationDate: modificationDate) - case let .directory(children): - var copiedChildren: [String: Node] = [:] - copiedChildren.reserveCapacity(children.count) - for (name, child) in children { - copiedChildren[name] = child.clone() - } - return Node(kind: .directory(copiedChildren), permissions: permissions, modificationDate: modificationDate) - } - } - } - - private var root: Node - - public init() { - root = Node(kind: .directory([:]), permissions: 0o755) - } - - public func configure(rootDirectory: URL) throws { - _ = rootDirectory - reset() - } - - public func configureForSession() throws { - reset() - } - - private func reset() { - root = Node(kind: .directory([:]), permissions: 0o755) - } - - public func stat(path: String) async throws -> FileInfo { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let node = try node(at: normalized, followFinalSymlink: false) - return fileInfo(for: node, path: normalized) - } - - public func listDirectory(path: String) async throws -> [DirectoryEntry] { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let node = try node(at: normalized, followFinalSymlink: true) - - guard case let .directory(children) = node.kind else { - throw posixError(ENOTDIR) - } - - return children.keys.sorted().compactMap { name in - guard let child = children[name] else { - return nil - } - let childPath = PathUtils.join(normalized, name) - return DirectoryEntry(name: name, info: fileInfo(for: child, path: childPath)) - } - } - - public func readFile(path: String) async throws -> Data { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let node = try node(at: normalized, followFinalSymlink: true) - - guard case let .file(data) = node.kind else { - throw posixError(EISDIR) - } - - return data - } - - public func writeFile(path: String, data: Data, append: Bool) async throws { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - guard normalized != "/" else { - throw posixError(EISDIR) - } - - if let symlinkTarget = try symlinkTargetIfPresent(at: normalized) { - let targetPath = PathUtils.normalize(path: symlinkTarget, currentDirectory: PathUtils.dirname(normalized)) - try await writeFile(path: targetPath, data: data, append: append) - return - } - - let (parent, name) = try parentDirectoryAndName(for: normalized) - var children = try directoryChildren(of: parent) - - if append, - let existing = children[name], - case let .file(existingData) = existing.kind { - existing.kind = .file(existingData + data) - existing.modificationDate = Date() - children[name] = existing - } else { - let node = Node(kind: .file(data), permissions: 0o644) - children[name] = node - } - - parent.kind = .directory(children) - parent.modificationDate = Date() - } - - public func createDirectory(path: String, recursive: Bool) async throws { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - if normalized == "/" { - return - } - - let components = PathUtils.splitComponents(normalized) - var current = root - - for (index, component) in components.enumerated() { - var children = try directoryChildren(of: current) - let isLast = index == components.count - 1 - - if let existing = children[component] { - guard existing.isDirectory else { - throw posixError(ENOTDIR) - } - - if isLast, !recursive { - throw posixError(EEXIST) - } - - current = existing - } else { - if !recursive, !isLast { - throw posixError(ENOENT) - } - - let directory = Node(kind: .directory([:]), permissions: 0o755) - children[component] = directory - current.kind = .directory(children) - current.modificationDate = Date() - current = directory - } - } - } - - public func remove(path: String, recursive: Bool) async throws { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - if normalized == "/" { - throw posixError(EPERM) - } - - guard let (parent, name, entry) = try parentDirectoryEntryIfPresent(for: normalized) else { - return - } - - if case let .directory(children) = entry.kind, !recursive, !children.isEmpty { - throw posixError(ENOTEMPTY) - } - - var parentChildren = try directoryChildren(of: parent) - parentChildren.removeValue(forKey: name) - parent.kind = .directory(parentChildren) - parent.modificationDate = Date() - } - - public func move(from sourcePath: String, to destinationPath: String) async throws { - try PathUtils.validate(sourcePath) - try PathUtils.validate(destinationPath) - let source = PathUtils.normalize(path: sourcePath, currentDirectory: "/") - let destination = PathUtils.normalize(path: destinationPath, currentDirectory: "/") - - if source == destination { - return - } - - guard let (sourceParent, sourceName, sourceNode) = try parentDirectoryEntryIfPresent(for: source) else { - throw posixError(ENOENT) - } - - if sourceNode.isDirectory, - (destination == source || destination.hasPrefix(source + "/")) { - throw posixError(EINVAL) - } - - let (destinationParent, destinationName) = try parentDirectoryAndName(for: destination) - var destinationChildren = try directoryChildren(of: destinationParent) - if destinationChildren[destinationName] != nil { - throw posixError(EEXIST) - } - - var sourceChildren = try directoryChildren(of: sourceParent) - sourceChildren.removeValue(forKey: sourceName) - sourceParent.kind = .directory(sourceChildren) - sourceParent.modificationDate = Date() - - destinationChildren[destinationName] = sourceNode - destinationParent.kind = .directory(destinationChildren) - destinationParent.modificationDate = Date() - } - - public func copy(from sourcePath: String, to destinationPath: String, recursive: Bool) async throws { - try PathUtils.validate(sourcePath) - try PathUtils.validate(destinationPath) - let source = PathUtils.normalize(path: sourcePath, currentDirectory: "/") - let destination = PathUtils.normalize(path: destinationPath, currentDirectory: "/") - - let sourceNode = try node(at: source, followFinalSymlink: false) - if sourceNode.isDirectory, !recursive { - throw posixError(EISDIR) - } - - let (destinationParent, destinationName) = try parentDirectoryAndName(for: destination) - var destinationChildren = try directoryChildren(of: destinationParent) - if destinationChildren[destinationName] != nil { - throw posixError(EEXIST) - } - - destinationChildren[destinationName] = sourceNode.clone() - destinationParent.kind = .directory(destinationChildren) - destinationParent.modificationDate = Date() - } - - public func createSymlink(path: String, target: String) async throws { - try PathUtils.validate(path) - try PathUtils.validate(target) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - guard normalized != "/" else { - throw posixError(EEXIST) - } - - let (parent, name) = try parentDirectoryAndName(for: normalized) - var children = try directoryChildren(of: parent) - if children[name] != nil { - throw posixError(EEXIST) - } - - children[name] = Node(kind: .symlink(target), permissions: 0o777) - parent.kind = .directory(children) - parent.modificationDate = Date() - } - - public func createHardLink(path: String, target: String) async throws { - try PathUtils.validate(path) - try PathUtils.validate(target) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - guard normalized != "/" else { - throw posixError(EEXIST) - } - - let source = PathUtils.normalize(path: target, currentDirectory: "/") - let sourceNode = try node(at: source, followFinalSymlink: false) - if sourceNode.isDirectory { - throw posixError(EPERM) - } - - let (parent, name) = try parentDirectoryAndName(for: normalized) - var children = try directoryChildren(of: parent) - if children[name] != nil { - throw posixError(EEXIST) - } - - children[name] = sourceNode - parent.kind = .directory(children) - parent.modificationDate = Date() - } - - public func readSymlink(path: String) async throws -> String { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let node = try node(at: normalized, followFinalSymlink: false) - - guard case let .symlink(target) = node.kind else { - throw posixError(EINVAL) - } - - return target - } - - public func setPermissions(path: String, permissions: Int) async throws { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let node = try node(at: normalized, followFinalSymlink: false) - node.permissions = permissions - node.modificationDate = Date() - } - - public func resolveRealPath(path: String) async throws -> String { - try PathUtils.validate(path) - return try resolvePath( - path: PathUtils.normalize(path: path, currentDirectory: "/"), - followFinalSymlink: true, - symlinkDepth: 0 - ) - } - - public func exists(path: String) async -> Bool { - do { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - _ = try node(at: normalized, followFinalSymlink: true) - return true - } catch { - return false - } - } - - public func glob(pattern: String, currentDirectory: String) async throws -> [String] { - try PathUtils.validate(pattern) - try PathUtils.validate(currentDirectory) - let normalizedPattern = PathUtils.normalize(path: pattern, currentDirectory: currentDirectory) - if !PathUtils.containsGlob(normalizedPattern) { - return await exists(path: normalizedPattern) ? [normalizedPattern] : [] - } - - let regex = try NSRegularExpression(pattern: PathUtils.globToRegex(normalizedPattern)) - let paths = allVirtualPaths() - - let matches = paths.filter { path in - let range = NSRange(path.startIndex.. [String] { - var paths = ["/"] - collectPaths(node: root, currentPath: "/", into: &paths) - return paths - } - - private func collectPaths(node: Node, currentPath: String, into paths: inout [String]) { - guard case let .directory(children) = node.kind else { - return - } - - for (name, child) in children.sorted(by: { $0.key < $1.key }) { - let childPath = PathUtils.join(currentPath, name) - paths.append(childPath) - collectPaths(node: child, currentPath: childPath, into: &paths) - } - } - - private func fileInfo(for node: Node, path: String) -> FileInfo { - FileInfo( - path: path, - isDirectory: node.isDirectory, - isSymbolicLink: node.isSymbolicLink, - size: node.size, - permissions: node.permissions, - modificationDate: node.modificationDate - ) - } - - private func directoryChildren(of node: Node) throws -> [String: Node] { - guard case let .directory(children) = node.kind else { - throw posixError(ENOTDIR) - } - return children - } - - private func parentDirectoryAndName(for path: String) throws -> (Node, String) { - guard path != "/" else { - throw posixError(EPERM) - } - - let parentPath = PathUtils.dirname(path) - let name = PathUtils.basename(path) - let parent = try node(at: parentPath, followFinalSymlink: true) - _ = try directoryChildren(of: parent) - return (parent, name) - } - - private func parentDirectoryEntryIfPresent(for path: String) throws -> (Node, String, Node)? { - let (parent, name) = try parentDirectoryAndName(for: path) - let children = try directoryChildren(of: parent) - guard let entry = children[name] else { - return nil - } - return (parent, name, entry) - } - - private func symlinkTargetIfPresent(at path: String) throws -> String? { - guard let (_, _, entry) = try parentDirectoryEntryIfPresent(for: path) else { - return nil - } - - if case let .symlink(target) = entry.kind { - return target - } - - return nil - } - - private func node(at path: String, followFinalSymlink: Bool, symlinkDepth: Int = 0) throws -> Node { - if symlinkDepth > 64 { - throw posixError(ELOOP) - } - - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - if normalized == "/" { - return root - } - - let components = PathUtils.splitComponents(normalized) - var current = root - var currentPath = "/" - - for (index, component) in components.enumerated() { - guard case let .directory(children) = current.kind else { - throw posixError(ENOTDIR) - } - - guard let child = children[component] else { - throw posixError(ENOENT) - } - - let isFinal = index == components.count - 1 - if case let .symlink(target) = child.kind, - (!isFinal || followFinalSymlink) { - let base = currentPath - let targetPath = PathUtils.normalize(path: target, currentDirectory: base) - let remaining = components.suffix(from: index + 1).joined(separator: "/") - let combined = remaining.isEmpty ? targetPath : PathUtils.join(targetPath, remaining) - return try node(at: combined, followFinalSymlink: followFinalSymlink, symlinkDepth: symlinkDepth + 1) - } - - current = child - currentPath = PathUtils.join(currentPath, component) - } - - return current - } - - private func resolvePath(path: String, followFinalSymlink: Bool, symlinkDepth: Int) throws -> String { - if symlinkDepth > 64 { - throw posixError(ELOOP) - } - - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - if normalized == "/" { - return "/" - } - - let components = PathUtils.splitComponents(normalized) - var current = root - var resolvedPath = "/" - - for (index, component) in components.enumerated() { - guard case let .directory(children) = current.kind else { - throw posixError(ENOTDIR) - } - - guard let child = children[component] else { - throw posixError(ENOENT) - } - - let isFinal = index == components.count - 1 - if case let .symlink(target) = child.kind, - (!isFinal || followFinalSymlink) { - let targetPath = PathUtils.normalize(path: target, currentDirectory: resolvedPath) - let remaining = components.suffix(from: index + 1).joined(separator: "/") - let combined = remaining.isEmpty ? targetPath : PathUtils.join(targetPath, remaining) - return try resolvePath(path: combined, followFinalSymlink: followFinalSymlink, symlinkDepth: symlinkDepth + 1) - } - - current = child - resolvedPath = PathUtils.join(resolvedPath, component) - } - - return resolvedPath - } - - private func posixError(_ code: Int32) -> NSError { - NSError(domain: NSPOSIXErrorDomain, code: Int(code)) - } -} diff --git a/Sources/Bash/FS/MountableFilesystem.swift b/Sources/Bash/FS/MountableFilesystem.swift deleted file mode 100644 index d3cf2ed..0000000 --- a/Sources/Bash/FS/MountableFilesystem.swift +++ /dev/null @@ -1,359 +0,0 @@ -import Foundation - -#if canImport(Darwin) -import Darwin -#elseif canImport(Glibc) -import Glibc -#endif - -public final class MountableFilesystem: SessionConfigurableFilesystem, @unchecked Sendable { - public struct Mount: Sendable { - public var mountPoint: String - public var filesystem: any ShellFilesystem - - public init(mountPoint: String, filesystem: any ShellFilesystem) { - self.mountPoint = PathUtils.normalize(path: mountPoint, currentDirectory: "/") - self.filesystem = filesystem - } - } - - private let base: any ShellFilesystem - private var mounts: [Mount] - - public init( - base: any ShellFilesystem = InMemoryFilesystem(), - mounts: [Mount] = [] - ) { - self.base = base - self.mounts = mounts.sorted { $0.mountPoint.count > $1.mountPoint.count } - } - - public func mount(_ mountPoint: String, filesystem: any ShellFilesystem) { - let mount = Mount(mountPoint: mountPoint, filesystem: filesystem) - mounts.append(mount) - mounts.sort { $0.mountPoint.count > $1.mountPoint.count } - } - - public func configure(rootDirectory: URL) throws { - try base.configure(rootDirectory: rootDirectory) - for mount in mounts { - if let configurable = mount.filesystem as? any SessionConfigurableFilesystem { - try configurable.configureForSession() - } - } - } - - public func configureForSession() throws { - guard let configurableBase = base as? any SessionConfigurableFilesystem else { - throw ShellError.unsupported("filesystem requires rootDirectory initializer") - } - try configurableBase.configureForSession() - for mount in mounts { - if let configurable = mount.filesystem as? any SessionConfigurableFilesystem { - try configurable.configureForSession() - } - } - } - - public func stat(path: String) async throws -> FileInfo { - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - if let resolved = resolveMounted(path: normalized) { - var info = try await resolved.filesystem.stat(path: resolved.relativePath) - info.path = normalized - return info - } - - if hasSyntheticDirectory(at: normalized) { - return FileInfo( - path: normalized, - isDirectory: true, - isSymbolicLink: false, - size: 0, - permissions: 0o755, - modificationDate: nil - ) - } - - return try await base.stat(path: normalized) - } - - public func listDirectory(path: String) async throws -> [DirectoryEntry] { - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - if let resolved = resolveMounted(path: normalized) { - let entries = try await resolved.filesystem.listDirectory(path: resolved.relativePath) - return entries.map { entry in - DirectoryEntry( - name: entry.name, - info: FileInfo( - path: PathUtils.join(normalized, entry.name), - isDirectory: entry.info.isDirectory, - isSymbolicLink: entry.info.isSymbolicLink, - size: entry.info.size, - permissions: entry.info.permissions, - modificationDate: entry.info.modificationDate - ) - ) - } - } - - var merged: [String: DirectoryEntry] = [:] - let baseHasPath = normalized == "/" ? true : await base.exists(path: normalized) - if baseHasPath { - if let baseEntries = try? await base.listDirectory(path: normalized) { - for entry in baseEntries { - merged[entry.name] = entry - } - } - } - - for syntheticName in syntheticChildMountNames(under: normalized) { - merged[syntheticName] = DirectoryEntry( - name: syntheticName, - info: FileInfo( - path: PathUtils.join(normalized, syntheticName), - isDirectory: true, - isSymbolicLink: false, - size: 0, - permissions: 0o755, - modificationDate: nil - ) - ) - } - - if merged.isEmpty, !hasSyntheticDirectory(at: normalized) { - throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOENT)) - } - - return merged.values.sorted { $0.name < $1.name } - } - - public func readFile(path: String) async throws -> Data { - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - if let resolved = resolveMounted(path: normalized) { - return try await resolved.filesystem.readFile(path: resolved.relativePath) - } - return try await base.readFile(path: normalized) - } - - public func writeFile(path: String, data: Data, append: Bool) async throws { - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let resolved = resolveWritable(path: normalized) - try await resolved.filesystem.writeFile(path: resolved.relativePath, data: data, append: append) - } - - public func createDirectory(path: String, recursive: Bool) async throws { - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let resolved = resolveWritable(path: normalized) - try await resolved.filesystem.createDirectory(path: resolved.relativePath, recursive: recursive) - } - - public func remove(path: String, recursive: Bool) async throws { - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let resolved = resolveWritable(path: normalized) - try await resolved.filesystem.remove(path: resolved.relativePath, recursive: recursive) - } - - public func move(from sourcePath: String, to destinationPath: String) async throws { - let source = resolveWritable(path: PathUtils.normalize(path: sourcePath, currentDirectory: "/")) - let destination = resolveWritable(path: PathUtils.normalize(path: destinationPath, currentDirectory: "/")) - if source.mountPoint == destination.mountPoint { - try await source.filesystem.move(from: source.relativePath, to: destination.relativePath) - return - } - - try await copyTree( - from: source.filesystem, - sourcePath: source.relativePath, - to: destination.filesystem, - destinationPath: destination.relativePath - ) - try await source.filesystem.remove(path: source.relativePath, recursive: true) - } - - public func copy(from sourcePath: String, to destinationPath: String, recursive: Bool) async throws { - let source = resolveWritable(path: PathUtils.normalize(path: sourcePath, currentDirectory: "/")) - let destination = resolveWritable(path: PathUtils.normalize(path: destinationPath, currentDirectory: "/")) - if source.mountPoint == destination.mountPoint { - try await source.filesystem.copy( - from: source.relativePath, - to: destination.relativePath, - recursive: recursive - ) - return - } - - let info = try await source.filesystem.stat(path: source.relativePath) - if info.isDirectory, !recursive { - throw NSError(domain: NSPOSIXErrorDomain, code: Int(EISDIR)) - } - try await copyTree( - from: source.filesystem, - sourcePath: source.relativePath, - to: destination.filesystem, - destinationPath: destination.relativePath - ) - } - - public func createSymlink(path: String, target: String) async throws { - let resolved = resolveWritable(path: PathUtils.normalize(path: path, currentDirectory: "/")) - try await resolved.filesystem.createSymlink(path: resolved.relativePath, target: target) - } - - public func createHardLink(path: String, target: String) async throws { - let link = resolveWritable(path: PathUtils.normalize(path: path, currentDirectory: "/")) - let targetResolved = resolveWritable(path: PathUtils.normalize(path: target, currentDirectory: "/")) - if link.mountPoint != targetResolved.mountPoint { - throw ShellError.unsupported("hard links across mounts are not supported") - } - try await link.filesystem.createHardLink(path: link.relativePath, target: targetResolved.relativePath) - } - - public func readSymlink(path: String) async throws -> String { - let resolved = resolveWritable(path: PathUtils.normalize(path: path, currentDirectory: "/")) - return try await resolved.filesystem.readSymlink(path: resolved.relativePath) - } - - public func setPermissions(path: String, permissions: Int) async throws { - let resolved = resolveWritable(path: PathUtils.normalize(path: path, currentDirectory: "/")) - try await resolved.filesystem.setPermissions(path: resolved.relativePath, permissions: permissions) - } - - public func resolveRealPath(path: String) async throws -> String { - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let resolved = resolveWritable(path: normalized) - let real = try await resolved.filesystem.resolveRealPath(path: resolved.relativePath) - return resolved.mountPoint == "/" ? real : PathUtils.join(resolved.mountPoint, String(real.dropFirst())) - } - - public func exists(path: String) async -> Bool { - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - if let resolved = resolveMounted(path: normalized) { - return await resolved.filesystem.exists(path: resolved.relativePath) - } - if hasSyntheticDirectory(at: normalized) { - return true - } - return await base.exists(path: normalized) - } - - public func glob(pattern: String, currentDirectory: String) async throws -> [String] { - let normalizedPattern = PathUtils.normalize(path: pattern, currentDirectory: currentDirectory) - if !PathUtils.containsGlob(normalizedPattern) { - return await exists(path: normalizedPattern) ? [normalizedPattern] : [] - } - - let regex = try NSRegularExpression(pattern: PathUtils.globToRegex(normalizedPattern)) - let paths = try await allPaths() - return paths.filter { path in - let range = NSRange(path.startIndex.. [String] { - var visited = Set() - var queue = ["/"] - var paths = ["/"] - - while let current = queue.first { - queue.removeFirst() - if visited.contains(current) { - continue - } - visited.insert(current) - - guard let entries = try? await listDirectory(path: current) else { - continue - } - - for entry in entries { - let childPath = PathUtils.join(current, entry.name) - paths.append(childPath) - if entry.info.isDirectory { - queue.append(childPath) - } - } - } - - return Array(Set(paths)) - } - - private func hasSyntheticDirectory(at path: String) -> Bool { - path == "/" || mounts.contains { parentPath(of: $0.mountPoint) == path } || syntheticChildMountNames(under: path).isEmpty == false - } - - private func syntheticChildMountNames(under path: String) -> [String] { - var names = Set() - for mount in mounts where mount.mountPoint != path { - guard isPath(mount.mountPoint, inside: path) else { - continue - } - let remaining = mount.mountPoint == "/" ? "" : String(mount.mountPoint.dropFirst(path == "/" ? 1 : path.count + 1)) - guard !remaining.isEmpty else { continue } - if let first = remaining.split(separator: "/").first { - names.insert(String(first)) - } - } - return names.sorted() - } - - private func parentPath(of path: String) -> String { - PathUtils.dirname(path) - } - - private func isPath(_ candidate: String, inside parent: String) -> Bool { - if parent == "/" { - return candidate.hasPrefix("/") && candidate != "/" - } - return candidate == parent || candidate.hasPrefix(parent + "/") - } - - private func resolveWritable(path: String) -> (mountPoint: String, filesystem: any ShellFilesystem, relativePath: String) { - resolveMounted(path: path) ?? ("/", base, path) - } - - private func resolveMounted(path: String) -> (mountPoint: String, filesystem: any ShellFilesystem, relativePath: String)? { - for mount in mounts { - if mount.mountPoint == path { - return (mount.mountPoint, mount.filesystem, "/") - } - - if mount.mountPoint != "/", path.hasPrefix(mount.mountPoint + "/") { - let suffix = String(path.dropFirst(mount.mountPoint.count)) - return (mount.mountPoint, mount.filesystem, suffix.isEmpty ? "/" : suffix) - } - } - return nil - } -} diff --git a/Sources/Bash/FS/OverlayFilesystem.swift b/Sources/Bash/FS/OverlayFilesystem.swift deleted file mode 100644 index 87be87e..0000000 --- a/Sources/Bash/FS/OverlayFilesystem.swift +++ /dev/null @@ -1,172 +0,0 @@ -import Foundation - -public final class OverlayFilesystem: SessionConfigurableFilesystem, @unchecked Sendable { - private let fileManager: FileManager - private let overlay: InMemoryFilesystem - private var rootURL: URL? - - public init(fileManager: FileManager = .default) { - self.fileManager = fileManager - overlay = InMemoryFilesystem() - } - - public convenience init(rootDirectory: URL, fileManager: FileManager = .default) throws { - self.init(fileManager: fileManager) - try configure(rootDirectory: rootDirectory) - } - - public func configure(rootDirectory: URL) throws { - rootURL = rootDirectory.standardizedFileURL - try rebuildOverlay() - } - - public func configureForSession() throws { - guard rootURL != nil else { - throw ShellError.unsupported("overlay filesystem requires rootDirectory") - } - try rebuildOverlay() - } - - public func stat(path: String) async throws -> FileInfo { - try await overlay.stat(path: path) - } - - public func listDirectory(path: String) async throws -> [DirectoryEntry] { - try await overlay.listDirectory(path: path) - } - - public func readFile(path: String) async throws -> Data { - try await overlay.readFile(path: path) - } - - public func writeFile(path: String, data: Data, append: Bool) async throws { - try await overlay.writeFile(path: path, data: data, append: append) - } - - public func createDirectory(path: String, recursive: Bool) async throws { - try await overlay.createDirectory(path: path, recursive: recursive) - } - - public func remove(path: String, recursive: Bool) async throws { - try await overlay.remove(path: path, recursive: recursive) - } - - public func move(from sourcePath: String, to destinationPath: String) async throws { - try await overlay.move(from: sourcePath, to: destinationPath) - } - - public func copy(from sourcePath: String, to destinationPath: String, recursive: Bool) async throws { - try await overlay.copy(from: sourcePath, to: destinationPath, recursive: recursive) - } - - public func createSymlink(path: String, target: String) async throws { - try await overlay.createSymlink(path: path, target: target) - } - - public func createHardLink(path: String, target: String) async throws { - try await overlay.createHardLink(path: path, target: target) - } - - public func readSymlink(path: String) async throws -> String { - try await overlay.readSymlink(path: path) - } - - public func setPermissions(path: String, permissions: Int) async throws { - try await overlay.setPermissions(path: path, permissions: permissions) - } - - public func resolveRealPath(path: String) async throws -> String { - try await overlay.resolveRealPath(path: path) - } - - public func exists(path: String) async -> Bool { - await overlay.exists(path: path) - } - - public func glob(pattern: String, currentDirectory: String) async throws -> [String] { - try await overlay.glob(pattern: pattern, currentDirectory: currentDirectory) - } - - private func rebuildOverlay() throws { - try overlay.configureForSession() - - guard let rootURL else { - return - } - - guard fileManager.fileExists(atPath: rootURL.path) else { - return - } - - let names = try fileManager.contentsOfDirectory(atPath: rootURL.path).sorted() - for name in names { - let childURL = rootURL.appendingPathComponent(name, isDirectory: true) - try importItem(at: childURL, virtualPath: "/" + name) - } - } - - private func importItem(at url: URL, virtualPath: String) throws { - let values = try url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) - let attributes = try fileManager.attributesOfItem(atPath: url.path) - let permissions = (attributes[.posixPermissions] as? NSNumber)?.intValue - - if values.isSymbolicLink == true { - let target = try fileManager.destinationOfSymbolicLink(atPath: url.path) - try performAsync { - try await self.overlay.createSymlink(path: virtualPath, target: target) - if let permissions { - try await self.overlay.setPermissions(path: virtualPath, permissions: permissions) - } - } - return - } - - if values.isDirectory == true { - try performAsync { - try await self.overlay.createDirectory(path: virtualPath, recursive: true) - if let permissions { - try await self.overlay.setPermissions(path: virtualPath, permissions: permissions) - } - } - - let children = try fileManager.contentsOfDirectory(atPath: url.path).sorted() - for child in children { - let childURL = url.appendingPathComponent(child, isDirectory: true) - try importItem(at: childURL, virtualPath: PathUtils.join(virtualPath, child)) - } - return - } - - let data = try Data(contentsOf: url) - try performAsync { - try await self.overlay.writeFile(path: virtualPath, data: data, append: false) - if let permissions { - try await self.overlay.setPermissions(path: virtualPath, permissions: permissions) - } - } - } - - private func performAsync( - _ operation: @escaping @Sendable () async throws -> Void - ) throws { - let semaphore = DispatchSemaphore(value: 0) - final class ErrorBox: @unchecked Sendable { - var error: Error? - } - let box = ErrorBox() - - Task { - defer { semaphore.signal() } - do { - try await operation() - } catch { - box.error = error - } - } - - semaphore.wait() - if let error = box.error { - throw error - } - } -} diff --git a/Sources/Bash/FS/ReadWriteFilesystem.swift b/Sources/Bash/FS/ReadWriteFilesystem.swift deleted file mode 100644 index 8e5b2f9..0000000 --- a/Sources/Bash/FS/ReadWriteFilesystem.swift +++ /dev/null @@ -1,309 +0,0 @@ -import Foundation - -public final class ReadWriteFilesystem: ShellFilesystem, @unchecked Sendable { - private let fileManager: FileManager - private var rootURL: URL? - private var resolvedRootPath: String? - - public init(fileManager: FileManager = .default) { - self.fileManager = fileManager - } - - public convenience init(rootDirectory: URL, fileManager: FileManager = .default) throws { - self.init(fileManager: fileManager) - try configure(rootDirectory: rootDirectory) - } - - public func configure(rootDirectory: URL) throws { - let standardized = rootDirectory.standardizedFileURL - try fileManager.createDirectory(at: standardized, withIntermediateDirectories: true) - let resolved = standardized.resolvingSymlinksInPath().standardizedFileURL - rootURL = standardized - resolvedRootPath = resolved.path - } - - public func stat(path: String) async throws -> FileInfo { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let url = try existingURL(for: normalized) - let attributes = try fileManager.attributesOfItem(atPath: url.path) - - let fileType = attributes[.type] as? FileAttributeType - let isDirectory = fileType == .typeDirectory - let isSymbolicLink = fileType == .typeSymbolicLink - let size = (attributes[.size] as? NSNumber)?.uint64Value ?? 0 - let permissions = (attributes[.posixPermissions] as? NSNumber)?.intValue ?? 0 - let modificationDate = attributes[.modificationDate] as? Date - - return FileInfo( - path: normalized, - isDirectory: isDirectory, - isSymbolicLink: isSymbolicLink, - size: size, - permissions: permissions, - modificationDate: modificationDate - ) - } - - public func listDirectory(path: String) async throws -> [DirectoryEntry] { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let url = try existingURL(for: normalized) - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory), isDirectory.boolValue else { - throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOTDIR)) - } - - let names = try fileManager.contentsOfDirectory(atPath: url.path).sorted() - var entries: [DirectoryEntry] = [] - entries.reserveCapacity(names.count) - for name in names { - let childPath = PathUtils.join(normalized, name) - let info = try await stat(path: childPath) - entries.append(DirectoryEntry(name: name, info: info)) - } - return entries - } - - public func readFile(path: String) async throws -> Data { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let url = try existingURL(for: normalized) - return try Data(contentsOf: url) - } - - public func writeFile(path: String, data: Data, append: Bool) async throws { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let url = try creationURL(for: normalized) - - let parent = url.deletingLastPathComponent() - try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) - - if append, fileManager.fileExists(atPath: url.path) { - let handle = try FileHandle(forWritingTo: url) - defer { try? handle.close() } - try handle.seekToEnd() - try handle.write(contentsOf: data) - } else { - try data.write(to: url, options: .atomic) - } - } - - public func createDirectory(path: String, recursive: Bool) async throws { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let url = try creationURL(for: normalized) - try fileManager.createDirectory(at: url, withIntermediateDirectories: recursive) - } - - public func remove(path: String, recursive: Bool) async throws { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let url = try existingURL(for: normalized) - - var isDirectory: ObjCBool = false - let exists = fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) - guard exists else { return } - - if isDirectory.boolValue, !recursive { - let contents = try fileManager.contentsOfDirectory(atPath: url.path) - if !contents.isEmpty { - throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOTEMPTY)) - } - } - - try fileManager.removeItem(at: url) - } - - public func move(from sourcePath: String, to destinationPath: String) async throws { - try PathUtils.validate(sourcePath) - try PathUtils.validate(destinationPath) - let source = try existingURL(for: PathUtils.normalize(path: sourcePath, currentDirectory: "/")) - let destination = try creationURL(for: PathUtils.normalize(path: destinationPath, currentDirectory: "/")) - let parent = destination.deletingLastPathComponent() - try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) - try fileManager.moveItem(at: source, to: destination) - } - - public func copy(from sourcePath: String, to destinationPath: String, recursive: Bool) async throws { - try PathUtils.validate(sourcePath) - try PathUtils.validate(destinationPath) - let sourceVirtual = PathUtils.normalize(path: sourcePath, currentDirectory: "/") - let source = try existingURL(for: sourceVirtual) - let destination = try creationURL(for: PathUtils.normalize(path: destinationPath, currentDirectory: "/")) - - let sourceInfo = try await stat(path: sourceVirtual) - if sourceInfo.isDirectory, !recursive { - throw NSError(domain: NSPOSIXErrorDomain, code: Int(EISDIR)) - } - - let parent = destination.deletingLastPathComponent() - try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) - - if sourceInfo.isDirectory { - try fileManager.copyItem(at: source, to: destination) - } else { - if fileManager.fileExists(atPath: destination.path) { - try fileManager.removeItem(at: destination) - } - try fileManager.copyItem(at: source, to: destination) - } - } - - public func createSymlink(path: String, target: String) async throws { - try PathUtils.validate(path) - try PathUtils.validate(target) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let url = try creationURL(for: normalized) - let parent = url.deletingLastPathComponent() - try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) - try fileManager.createSymbolicLink(atPath: url.path, withDestinationPath: target) - } - - public func createHardLink(path: String, target: String) async throws { - try PathUtils.validate(path) - try PathUtils.validate(target) - let normalizedLink = PathUtils.normalize(path: path, currentDirectory: "/") - let normalizedTarget = PathUtils.normalize(path: target, currentDirectory: "/") - let linkURL = try creationURL(for: normalizedLink) - let targetURL = try existingURL(for: normalizedTarget) - - let parent = linkURL.deletingLastPathComponent() - try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) - try fileManager.linkItem(at: targetURL, to: linkURL) - } - - public func readSymlink(path: String) async throws -> String { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let url = try existingURL(for: normalized) - return try fileManager.destinationOfSymbolicLink(atPath: url.path) - } - - public func setPermissions(path: String, permissions: Int) async throws { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let url = try existingURL(for: normalized) - try fileManager.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path) - } - - public func resolveRealPath(path: String) async throws -> String { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let url = try existingURL(for: normalized) - let resolved = url.resolvingSymlinksInPath().standardizedFileURL - try ensureInsideRoot(resolved) - return virtualPath(from: resolved) - } - - public func exists(path: String) async -> Bool { - do { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: "/") - let url = try existingOrPotentialURL(for: normalized) - return fileManager.fileExists(atPath: url.path) - } catch { - return false - } - } - - public func glob(pattern: String, currentDirectory: String) async throws -> [String] { - try PathUtils.validate(pattern) - try PathUtils.validate(currentDirectory) - let normalizedPattern = PathUtils.normalize(path: pattern, currentDirectory: currentDirectory) - if !PathUtils.containsGlob(normalizedPattern) { - return await exists(path: normalizedPattern) ? [normalizedPattern] : [] - } - - let regex = try NSRegularExpression(pattern: PathUtils.globToRegex(normalizedPattern)) - let allPaths = try allVirtualPaths() - - let matches = allPaths.filter { path in - let range = NSRange(path.startIndex.. [String] { - let root = try requireRoot() - var paths = ["/"] - - guard let enumerator = fileManager.enumerator(at: root, includingPropertiesForKeys: nil) else { - return paths - } - - for case let url as URL in enumerator { - paths.append(virtualPath(from: url)) - } - - return paths - } - - private func existingOrPotentialURL(for virtualPath: String) throws -> URL { - let root = try requireRoot() - let absolute = virtualPath.hasPrefix("/") ? virtualPath : "/\(virtualPath)" - if absolute == "/" { - return root - } - - let relative = String(absolute.dropFirst()) - return root.appendingPathComponent(relative) - } - - private func existingURL(for virtualPath: String) throws -> URL { - let url = try existingOrPotentialURL(for: virtualPath) - try ensureInsideRoot(url) - return url - } - - private func creationURL(for virtualPath: String) throws -> URL { - let url = try existingOrPotentialURL(for: virtualPath) - let parent = url.deletingLastPathComponent() - try ensureInsideRoot(parent) - return url - } - - private func ensureInsideRoot(_ url: URL) throws { - let resolved = url.resolvingSymlinksInPath().standardizedFileURL.path - guard let root = resolvedRootPath else { - throw ShellError.unsupported("filesystem is not configured") - } - guard resolved == root || resolved.hasPrefix(root + "/") else { - throw ShellError.invalidPath(virtualPath(from: url)) - } - } - - private func virtualPath(from physicalURL: URL) -> String { - guard let root = try? requireRoot() else { - return "/" - } - - let rootPath = root.path - let path = physicalURL.standardizedFileURL.path - - if path == rootPath { - return "/" - } - - guard path.hasPrefix(rootPath) else { - return "/" - } - - let start = path.index(path.startIndex, offsetBy: rootPath.count) - let suffix = String(path[start...]).trimmingCharacters(in: CharacterSet(charactersIn: "/")) - if suffix.isEmpty { - return "/" - } - return "/" + suffix - } - - private func requireRoot() throws -> URL { - guard let rootURL else { - throw ShellError.unsupported("filesystem is not configured") - } - return rootURL - } -} diff --git a/Sources/Bash/FS/SandboxFilesystem.swift b/Sources/Bash/FS/SandboxFilesystem.swift deleted file mode 100644 index 1f6a091..0000000 --- a/Sources/Bash/FS/SandboxFilesystem.swift +++ /dev/null @@ -1,117 +0,0 @@ -import Foundation - -public final class SandboxFilesystem: SessionConfigurableFilesystem, @unchecked Sendable { - public enum Root: Sendable { - case documents - case caches - case temporary - case appGroup(String) - case url(URL) - } - - private let root: Root - private let fileManager: FileManager - private let backing: ReadWriteFilesystem - - public init(root: Root, fileManager: FileManager = .default) { - self.root = root - self.fileManager = fileManager - backing = ReadWriteFilesystem(fileManager: fileManager) - } - - public func configureForSession() throws { - let resolvedRoot = try resolveRootURL() - try backing.configure(rootDirectory: resolvedRoot) - } - - public func configure(rootDirectory: URL) throws { - try backing.configure(rootDirectory: rootDirectory) - } - - public func stat(path: String) async throws -> FileInfo { - try await backing.stat(path: path) - } - - public func listDirectory(path: String) async throws -> [DirectoryEntry] { - try await backing.listDirectory(path: path) - } - - public func readFile(path: String) async throws -> Data { - try await backing.readFile(path: path) - } - - public func writeFile(path: String, data: Data, append: Bool) async throws { - try await backing.writeFile(path: path, data: data, append: append) - } - - public func createDirectory(path: String, recursive: Bool) async throws { - try await backing.createDirectory(path: path, recursive: recursive) - } - - public func remove(path: String, recursive: Bool) async throws { - try await backing.remove(path: path, recursive: recursive) - } - - public func move(from sourcePath: String, to destinationPath: String) async throws { - try await backing.move(from: sourcePath, to: destinationPath) - } - - public func copy(from sourcePath: String, to destinationPath: String, recursive: Bool) async throws { - try await backing.copy(from: sourcePath, to: destinationPath, recursive: recursive) - } - - public func createSymlink(path: String, target: String) async throws { - try await backing.createSymlink(path: path, target: target) - } - - public func createHardLink(path: String, target: String) async throws { - try await backing.createHardLink(path: path, target: target) - } - - public func readSymlink(path: String) async throws -> String { - try await backing.readSymlink(path: path) - } - - public func setPermissions(path: String, permissions: Int) async throws { - try await backing.setPermissions(path: path, permissions: permissions) - } - - public func resolveRealPath(path: String) async throws -> String { - try await backing.resolveRealPath(path: path) - } - - public func exists(path: String) async -> Bool { - await backing.exists(path: path) - } - - public func glob(pattern: String, currentDirectory: String) async throws -> [String] { - try await backing.glob(pattern: pattern, currentDirectory: currentDirectory) - } - - private func resolveRootURL() throws -> URL { - switch root { - case .temporary: - return fileManager.temporaryDirectory - case .documents: - guard let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { - throw ShellError.unsupported("documents directory is unavailable") - } - return url - case .caches: - guard let url = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first else { - throw ShellError.unsupported("caches directory is unavailable") - } - return url - case let .appGroup(identifier): - guard identifier.hasPrefix("group.") else { - throw ShellError.unsupported("invalid app group identifier: \(identifier)") - } - guard let url = fileManager.containerURL(forSecurityApplicationGroupIdentifier: identifier) else { - throw ShellError.unsupported("app group container unavailable: \(identifier)") - } - return url - case let .url(url): - return url - } - } -} diff --git a/Sources/Bash/FS/SecurityScopedFilesystem.swift b/Sources/Bash/FS/SecurityScopedFilesystem.swift deleted file mode 100644 index a2de64c..0000000 --- a/Sources/Bash/FS/SecurityScopedFilesystem.swift +++ /dev/null @@ -1,202 +0,0 @@ -import Foundation - -public final class SecurityScopedFilesystem: SessionConfigurableFilesystem, @unchecked Sendable { - public enum AccessMode: Sendable { - case readOnly - case readWrite - } - - private let mode: AccessMode - private let fileManager: FileManager - private let backing: ReadWriteFilesystem - - private var scopedURL: URL - private var cachedBookmarkData: Data? - private var didStartSecurityScope = false - - public init(url: URL, mode: AccessMode = .readWrite, fileManager: FileManager = .default) throws { - self.mode = mode - self.fileManager = fileManager - backing = ReadWriteFilesystem(fileManager: fileManager) - scopedURL = url.standardizedFileURL - cachedBookmarkData = nil - } - - public init(bookmarkData: Data, mode: AccessMode = .readWrite, fileManager: FileManager = .default) throws { - #if os(tvOS) || os(watchOS) - throw ShellError.unsupported("security-scoped URLs not supported on this platform") - #else - self.mode = mode - self.fileManager = fileManager - backing = ReadWriteFilesystem(fileManager: fileManager) - - var isStale = false - let resolvedURL = try URL( - resolvingBookmarkData: bookmarkData, - options: Self.bookmarkResolutionOptions, - relativeTo: nil, - bookmarkDataIsStale: &isStale - ) - - scopedURL = resolvedURL.standardizedFileURL - if isStale { - cachedBookmarkData = try scopedURL.bookmarkData( - options: Self.bookmarkCreationOptions, - includingResourceValuesForKeys: nil, - relativeTo: nil - ) - } else { - cachedBookmarkData = bookmarkData - } - #endif - } - - deinit { - #if os(iOS) || os(macOS) - if didStartSecurityScope { - scopedURL.stopAccessingSecurityScopedResource() - } - #endif - } - - public func makeBookmarkData() throws -> Data { - #if os(tvOS) || os(watchOS) - throw ShellError.unsupported("security-scoped URLs not supported on this platform") - #else - if let cachedBookmarkData { - return cachedBookmarkData - } - - let bookmarkData = try scopedURL.bookmarkData( - options: Self.bookmarkCreationOptions, - includingResourceValuesForKeys: nil, - relativeTo: nil - ) - cachedBookmarkData = bookmarkData - return bookmarkData - #endif - } - - public func saveBookmark(id: String, store: any BookmarkStore) async throws { - let data = try makeBookmarkData() - try await store.saveBookmark(data, for: id) - } - - public static func loadBookmark( - id: String, - store: any BookmarkStore, - mode: AccessMode = .readWrite, - fileManager: FileManager = .default - ) async throws -> SecurityScopedFilesystem { - guard let data = try await store.loadBookmark(for: id) else { - throw ShellError.unsupported("bookmark not found: \(id)") - } - return try SecurityScopedFilesystem(bookmarkData: data, mode: mode, fileManager: fileManager) - } - - public func configureForSession() throws { - #if os(tvOS) || os(watchOS) - throw ShellError.unsupported("security-scoped URLs not supported on this platform") - #elseif os(iOS) - if !didStartSecurityScope { - guard scopedURL.startAccessingSecurityScopedResource() else { - throw ShellError.unsupported("could not start security-scoped access") - } - didStartSecurityScope = true - } - #elseif os(macOS) - if !didStartSecurityScope { - didStartSecurityScope = scopedURL.startAccessingSecurityScopedResource() - } - #endif - - try backing.configure(rootDirectory: scopedURL) - } - - public func configure(rootDirectory: URL) throws { - scopedURL = rootDirectory.standardizedFileURL - try backing.configure(rootDirectory: scopedURL) - } - - public func stat(path: String) async throws -> FileInfo { - try await backing.stat(path: path) - } - - public func listDirectory(path: String) async throws -> [DirectoryEntry] { - try await backing.listDirectory(path: path) - } - - public func readFile(path: String) async throws -> Data { - try await backing.readFile(path: path) - } - - public func writeFile(path: String, data: Data, append: Bool) async throws { - try ensureWritable() - try await backing.writeFile(path: path, data: data, append: append) - } - - public func createDirectory(path: String, recursive: Bool) async throws { - try ensureWritable() - try await backing.createDirectory(path: path, recursive: recursive) - } - - public func remove(path: String, recursive: Bool) async throws { - try ensureWritable() - try await backing.remove(path: path, recursive: recursive) - } - - public func move(from sourcePath: String, to destinationPath: String) async throws { - try ensureWritable() - try await backing.move(from: sourcePath, to: destinationPath) - } - - public func copy(from sourcePath: String, to destinationPath: String, recursive: Bool) async throws { - try ensureWritable() - try await backing.copy(from: sourcePath, to: destinationPath, recursive: recursive) - } - - public func createSymlink(path: String, target: String) async throws { - try ensureWritable() - try await backing.createSymlink(path: path, target: target) - } - - public func createHardLink(path: String, target: String) async throws { - try ensureWritable() - try await backing.createHardLink(path: path, target: target) - } - - public func readSymlink(path: String) async throws -> String { - try await backing.readSymlink(path: path) - } - - public func setPermissions(path: String, permissions: Int) async throws { - try ensureWritable() - try await backing.setPermissions(path: path, permissions: permissions) - } - - public func resolveRealPath(path: String) async throws -> String { - try await backing.resolveRealPath(path: path) - } - - public func exists(path: String) async -> Bool { - await backing.exists(path: path) - } - - public func glob(pattern: String, currentDirectory: String) async throws -> [String] { - try await backing.glob(pattern: pattern, currentDirectory: currentDirectory) - } - - private func ensureWritable() throws { - guard mode == .readWrite else { - throw ShellError.unsupported("filesystem is read-only") - } - } - - #if os(macOS) || targetEnvironment(macCatalyst) - private static let bookmarkCreationOptions: URL.BookmarkCreationOptions = [.withSecurityScope] - private static let bookmarkResolutionOptions: URL.BookmarkResolutionOptions = [.withSecurityScope] - #else - private static let bookmarkCreationOptions: URL.BookmarkCreationOptions = [] - private static let bookmarkResolutionOptions: URL.BookmarkResolutionOptions = [] - #endif -} diff --git a/Sources/Bash/FS/SessionConfigurableFilesystem.swift b/Sources/Bash/FS/SessionConfigurableFilesystem.swift deleted file mode 100644 index f747e37..0000000 --- a/Sources/Bash/FS/SessionConfigurableFilesystem.swift +++ /dev/null @@ -1,5 +0,0 @@ -import Foundation - -public protocol SessionConfigurableFilesystem: ShellFilesystem { - func configureForSession() throws -} diff --git a/Sources/Bash/FS/ShellFilesystem.swift b/Sources/Bash/FS/ShellFilesystem.swift deleted file mode 100644 index b266d26..0000000 --- a/Sources/Bash/FS/ShellFilesystem.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation - -public protocol ShellFilesystem: AnyObject, Sendable { - func configure(rootDirectory: URL) throws - - func stat(path: String) async throws -> FileInfo - func listDirectory(path: String) async throws -> [DirectoryEntry] - func readFile(path: String) async throws -> Data - func writeFile(path: String, data: Data, append: Bool) async throws - func createDirectory(path: String, recursive: Bool) async throws - func remove(path: String, recursive: Bool) async throws - func move(from sourcePath: String, to destinationPath: String) async throws - func copy(from sourcePath: String, to destinationPath: String, recursive: Bool) async throws - func createSymlink(path: String, target: String) async throws - func createHardLink(path: String, target: String) async throws - func readSymlink(path: String) async throws -> String - func setPermissions(path: String, permissions: Int) async throws - func resolveRealPath(path: String) async throws -> String - - func exists(path: String) async -> Bool - func glob(pattern: String, currentDirectory: String) async throws -> [String] -} diff --git a/Sources/Bash/FS/UserDefaultsBookmarkStore.swift b/Sources/Bash/FS/UserDefaultsBookmarkStore.swift deleted file mode 100644 index 1a2ae2c..0000000 --- a/Sources/Bash/FS/UserDefaultsBookmarkStore.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation - -public actor UserDefaultsBookmarkStore: BookmarkStore { - private let defaults: UserDefaults - private let keyPrefix: String - - public init(suiteName: String? = nil, keyPrefix: String = "bashswift.bookmark.") { - defaults = suiteName.flatMap(UserDefaults.init(suiteName:)) ?? .standard - self.keyPrefix = keyPrefix - } - - public func saveBookmark(_ data: Data, for id: String) async throws { - defaults.set(data, forKey: key(for: id)) - } - - public func loadBookmark(for id: String) async throws -> Data? { - defaults.data(forKey: key(for: id)) - } - - public func deleteBookmark(for id: String) async throws { - defaults.removeObject(forKey: key(for: id)) - } - - private func key(for id: String) -> String { - keyPrefix + id - } -} diff --git a/Sources/Bash/Support/PermissionedShellFilesystem.swift b/Sources/Bash/Support/PermissionedShellFilesystem.swift new file mode 100644 index 0000000..f48c874 --- /dev/null +++ b/Sources/Bash/Support/PermissionedShellFilesystem.swift @@ -0,0 +1,331 @@ +import Foundation + +final class PermissionedShellFilesystem: ShellFilesystem, @unchecked Sendable { + let base: any ShellFilesystem + private let commandName: String + private let permissionAuthorizer: any PermissionAuthorizing + private let executionControl: ExecutionControl? + + init( + base: any ShellFilesystem, + commandName: String, + permissionAuthorizer: any PermissionAuthorizing, + executionControl: ExecutionControl? + ) { + self.base = Self.unwrap(base) + self.commandName = commandName + self.permissionAuthorizer = permissionAuthorizer + self.executionControl = executionControl + } + + static func unwrap(_ filesystem: any ShellFilesystem) -> any ShellFilesystem { + if let filesystem = filesystem as? PermissionedShellFilesystem { + return filesystem.base + } + return filesystem + } + + func configure(rootDirectory: URL) throws { + try base.configure(rootDirectory: rootDirectory) + } + + func stat(path: String) async throws -> FileInfo { + let normalized = try normalizedPath(path) + try await authorize( + .init( + command: commandName, + kind: .filesystem( + FilesystemPermissionRequest( + operation: .stat, + path: normalized + ) + ) + ) + ) + return try await base.stat(path: normalized) + } + + func listDirectory(path: String) async throws -> [DirectoryEntry] { + let normalized = try normalizedPath(path) + try await authorize( + .init( + command: commandName, + kind: .filesystem( + FilesystemPermissionRequest( + operation: .listDirectory, + path: normalized + ) + ) + ) + ) + return try await base.listDirectory(path: normalized) + } + + func readFile(path: String) async throws -> Data { + let normalized = try normalizedPath(path) + try await authorize( + .init( + command: commandName, + kind: .filesystem( + FilesystemPermissionRequest( + operation: .readFile, + path: normalized + ) + ) + ) + ) + return try await base.readFile(path: normalized) + } + + func writeFile(path: String, data: Data, append: Bool) async throws { + let normalized = try normalizedPath(path) + try await authorize( + .init( + command: commandName, + kind: .filesystem( + FilesystemPermissionRequest( + operation: .writeFile, + path: normalized, + append: append + ) + ) + ) + ) + try await base.writeFile(path: normalized, data: data, append: append) + } + + func createDirectory(path: String, recursive: Bool) async throws { + let normalized = try normalizedPath(path) + try await authorize( + .init( + command: commandName, + kind: .filesystem( + FilesystemPermissionRequest( + operation: .createDirectory, + path: normalized, + recursive: recursive + ) + ) + ) + ) + try await base.createDirectory(path: normalized, recursive: recursive) + } + + func remove(path: String, recursive: Bool) async throws { + let normalized = try normalizedPath(path) + try await authorize( + .init( + command: commandName, + kind: .filesystem( + FilesystemPermissionRequest( + operation: .remove, + path: normalized, + recursive: recursive + ) + ) + ) + ) + try await base.remove(path: normalized, recursive: recursive) + } + + func move(from sourcePath: String, to destinationPath: String) async throws { + let normalizedSource = try normalizedPath(sourcePath) + let normalizedDestination = try normalizedPath(destinationPath) + try await authorize( + .init( + command: commandName, + kind: .filesystem( + FilesystemPermissionRequest( + operation: .move, + sourcePath: normalizedSource, + destinationPath: normalizedDestination + ) + ) + ) + ) + try await base.move(from: normalizedSource, to: normalizedDestination) + } + + func copy(from sourcePath: String, to destinationPath: String, recursive: Bool) async throws { + let normalizedSource = try normalizedPath(sourcePath) + let normalizedDestination = try normalizedPath(destinationPath) + try await authorize( + .init( + command: commandName, + kind: .filesystem( + FilesystemPermissionRequest( + operation: .copy, + sourcePath: normalizedSource, + destinationPath: normalizedDestination, + recursive: recursive + ) + ) + ) + ) + try await base.copy(from: normalizedSource, to: normalizedDestination, recursive: recursive) + } + + func createSymlink(path: String, target: String) async throws { + let normalizedPath = try normalizedPath(path) + try PathUtils.validate(target) + let normalizedTarget = PathUtils.normalize( + path: target, + currentDirectory: PathUtils.dirname(normalizedPath) + ) + try await authorize( + .init( + command: commandName, + kind: .filesystem( + FilesystemPermissionRequest( + operation: .createSymlink, + path: normalizedPath, + destinationPath: normalizedTarget + ) + ) + ) + ) + try await base.createSymlink(path: normalizedPath, target: target) + } + + func createHardLink(path: String, target: String) async throws { + let normalizedLinkPath = try normalizedPath(path) + let normalizedTarget = try normalizedPath(target) + try await authorize( + .init( + command: commandName, + kind: .filesystem( + FilesystemPermissionRequest( + operation: .createHardLink, + path: normalizedLinkPath, + destinationPath: normalizedTarget + ) + ) + ) + ) + try await base.createHardLink(path: normalizedLinkPath, target: normalizedTarget) + } + + func readSymlink(path: String) async throws -> String { + let normalized = try normalizedPath(path) + try await authorize( + .init( + command: commandName, + kind: .filesystem( + FilesystemPermissionRequest( + operation: .readSymlink, + path: normalized + ) + ) + ) + ) + return try await base.readSymlink(path: normalized) + } + + func setPermissions(path: String, permissions: Int) async throws { + let normalized = try normalizedPath(path) + try await authorize( + .init( + command: commandName, + kind: .filesystem( + FilesystemPermissionRequest( + operation: .setPermissions, + path: normalized + ) + ) + ) + ) + try await base.setPermissions(path: normalized, permissions: permissions) + } + + func resolveRealPath(path: String) async throws -> String { + let normalized = try normalizedPath(path) + try await authorize( + .init( + command: commandName, + kind: .filesystem( + FilesystemPermissionRequest( + operation: .resolveRealPath, + path: normalized + ) + ) + ) + ) + return try await base.resolveRealPath(path: normalized) + } + + func exists(path: String) async -> Bool { + do { + let normalized = try normalizedPath(path) + try await authorize( + .init( + command: commandName, + kind: .filesystem( + FilesystemPermissionRequest( + operation: .exists, + path: normalized + ) + ) + ) + ) + return await base.exists(path: normalized) + } catch { + return false + } + } + + func glob(pattern: String, currentDirectory: String) async throws -> [String] { + try PathUtils.validate(pattern) + let normalizedCurrentDirectory = try normalizedPath(currentDirectory) + let normalizedPattern = PathUtils.normalize( + path: pattern, + currentDirectory: normalizedCurrentDirectory + ) + try await authorize( + .init( + command: commandName, + kind: .filesystem( + FilesystemPermissionRequest( + operation: .glob, + path: normalizedPattern, + destinationPath: normalizedCurrentDirectory + ) + ) + ) + ) + return try await base.glob( + pattern: normalizedPattern, + currentDirectory: normalizedCurrentDirectory + ) + } + + private func normalizedPath(_ path: String) throws -> String { + try PathUtils.validate(path) + return PathUtils.normalize(path: path, currentDirectory: "/") + } + + private func authorize(_ request: PermissionRequest) async throws { + let decision = await authorizePermissionRequest( + request, + using: permissionAuthorizer, + pausing: executionControl + ) + + if case let .deny(message) = decision { + throw ShellError.unsupported( + message ?? defaultDenialMessage(for: request) + ) + } + } + + private func defaultDenialMessage(for request: PermissionRequest) -> String { + guard case let .filesystem(filesystem) = request.kind else { + return "filesystem access denied" + } + + let target = filesystem.path + ?? filesystem.sourcePath + ?? filesystem.destinationPath + ?? "" + return "filesystem access denied: \(filesystem.operation.rawValue) \(target)" + } +} diff --git a/Sources/Bash/Support/Permissions.swift b/Sources/Bash/Support/Permissions.swift index 491e796..0fc7576 100644 --- a/Sources/Bash/Support/Permissions.swift +++ b/Sources/Bash/Support/Permissions.swift @@ -9,6 +9,7 @@ import Glibc public struct PermissionRequest: Sendable, Hashable { public enum Kind: Sendable, Hashable { case network(NetworkPermissionRequest) + case filesystem(FilesystemPermissionRequest) } public var command: String @@ -30,6 +31,49 @@ public struct NetworkPermissionRequest: Sendable, Hashable { } } +public enum FilesystemPermissionOperation: String, Sendable, Hashable { + case stat + case listDirectory + case readFile + case writeFile + case createDirectory + case remove + case move + case copy + case createSymlink + case createHardLink + case readSymlink + case setPermissions + case resolveRealPath + case exists + case glob +} + +public struct FilesystemPermissionRequest: Sendable, Hashable { + public var operation: FilesystemPermissionOperation + public var path: String? + public var sourcePath: String? + public var destinationPath: String? + public var append: Bool + public var recursive: Bool + + public init( + operation: FilesystemPermissionOperation, + path: String? = nil, + sourcePath: String? = nil, + destinationPath: String? = nil, + append: Bool = false, + recursive: Bool = false + ) { + self.operation = operation + self.path = path + self.sourcePath = sourcePath + self.destinationPath = destinationPath + self.append = append + self.recursive = recursive + } +} + public struct NetworkPolicy: Sendable { public static let disabled = NetworkPolicy() public static let unrestricted = NetworkPolicy(allowsHTTPRequests: true) @@ -144,6 +188,28 @@ actor PermissionAuthorizer: PermissionAuthorizing { } } +func authorizePermissionRequest( + _ request: PermissionRequest, + using authorizer: any PermissionAuthorizing, + pausing executionControl: ExecutionControl? +) async -> PermissionDecision { + if let authorizer = authorizer as? PermissionAuthorizer { + return await authorizer.authorize(request, pausing: executionControl) + } + + if let executionControl { + await executionControl.beginPermissionPause() + } + + let decision = await authorizer.authorize(request) + + if let executionControl { + await executionControl.endPermissionPause() + } + + return decision +} + private enum PermissionPolicyEvaluator { static func denialMessage( for request: PermissionRequest, @@ -152,6 +218,8 @@ private enum PermissionPolicyEvaluator { switch request.kind { case let .network(networkRequest): denialMessage(for: networkRequest, networkPolicy: networkPolicy) + case .filesystem: + nil } } diff --git a/Sources/Bash/Support/Types.swift b/Sources/Bash/Support/Types.swift index f51763a..4cd6610 100644 --- a/Sources/Bash/Support/Types.swift +++ b/Sources/Bash/Support/Types.swift @@ -1,4 +1,5 @@ import Foundation +import Workspace public struct CommandResult: Sendable { public var stdout: Data @@ -198,41 +199,6 @@ public enum ShellError: Error, CustomStringConvertible, Sendable { } } -public struct FileInfo: Sendable { - public var path: String - public var isDirectory: Bool - public var isSymbolicLink: Bool - public var size: UInt64 - public var permissions: Int - public var modificationDate: Date? - - public init( - path: String, - isDirectory: Bool, - isSymbolicLink: Bool, - size: UInt64, - permissions: Int, - modificationDate: Date? - ) { - self.path = path - self.isDirectory = isDirectory - self.isSymbolicLink = isSymbolicLink - self.size = size - self.permissions = permissions - self.modificationDate = modificationDate - } -} - -public struct DirectoryEntry: Sendable { - public var name: String - public var info: FileInfo - - public init(name: String, info: FileInfo) { - self.name = name - self.info = info - } -} - actor SecretExposureTracker { private var replacements: Set = [] diff --git a/Sources/Bash/WorkspaceCompat.swift b/Sources/Bash/WorkspaceCompat.swift new file mode 100644 index 0000000..f6755fd --- /dev/null +++ b/Sources/Bash/WorkspaceCompat.swift @@ -0,0 +1,5 @@ +@_exported import Workspace + +public typealias ShellFilesystem = WorkspaceFilesystem + +typealias PathUtils = WorkspacePath diff --git a/Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift b/Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift index 5c78a33..a26d3fd 100644 --- a/Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift +++ b/Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift @@ -142,6 +142,8 @@ struct CPythonRuntimeIntegrationTests { return .deny(message: "blocked by callback") } return .allow + case .filesystem: + return .allow } } ) diff --git a/Tests/BashTests/FilesystemOptionsTests.swift b/Tests/BashTests/FilesystemOptionsTests.swift index e5d5e2f..981cc7a 100644 --- a/Tests/BashTests/FilesystemOptionsTests.swift +++ b/Tests/BashTests/FilesystemOptionsTests.swift @@ -24,6 +24,31 @@ struct FilesystemOptionsTests { #expect(ls.stdoutString.contains("rootless.txt")) } + @Test("bash reexports workspace filesystem shims") + func bashReexportsWorkspaceFilesystemShims() async throws { + let workspaceFilesystem: any WorkspaceFilesystem = InMemoryFilesystem() + let shellFilesystem: any ShellFilesystem = workspaceFilesystem + let inMemoryFilesystem = InMemoryFilesystem() + try await inMemoryFilesystem.writeFile(path: "/note.txt", data: Data("shim".utf8), append: false) + inMemoryFilesystem.reset() + + let info = FileInfo( + path: "/note.txt", + isDirectory: false, + isSymbolicLink: false, + size: 4, + permissions: 0o644, + modificationDate: nil + ) + let entry = DirectoryEntry(name: "note.txt", info: info) + let error = WorkspaceError.unsupported("shim check") + + #expect(await shellFilesystem.exists(path: "/")) + #expect(!(await inMemoryFilesystem.exists(path: "/note.txt"))) + #expect(entry.info.path == "/note.txt") + #expect(error.description.contains("shim check")) + } + @Test("overlay filesystem snapshots disk and keeps writes in memory") func overlayFilesystemSnapshotsDiskAndKeepsWritesInMemory() async throws { let root = try TestSupport.makeTempDirectory(prefix: "BashOverlay") @@ -100,8 +125,8 @@ struct FilesystemOptionsTests { #expect(!FileManager.default.fileExists(atPath: workspaceRoot.appendingPathComponent("guide.txt").path)) } - @Test("rootless session init rejects non-configurable filesystem") - func rootlessSessionInitRejectsNonConfigurableFilesystem() async { + @Test("rootless session init rejects unconfigured read-write filesystem") + func rootlessSessionInitRejectsUnconfiguredReadWriteFilesystem() async { do { _ = try await BashSession( options: SessionOptions( @@ -113,8 +138,8 @@ struct FilesystemOptionsTests { ) ) Issue.record("expected unsupported error") - } catch let error as ShellError { - #expect(error.description.contains("filesystem requires rootDirectory initializer")) + } catch let error as WorkspaceError { + #expect(error.description.contains("filesystem is not configured")) } catch { Issue.record("unexpected error: \(error)") } @@ -145,22 +170,19 @@ struct FilesystemOptionsTests { @Test("sandbox documents and caches roots configure") func sandboxDocumentsAndCachesRootsConfigure() async throws { - let documents = SandboxFilesystem(root: .documents) - try documents.configureForSession() + let documents = try SandboxFilesystem(root: .documents) #expect(await documents.exists(path: "/")) - let caches = SandboxFilesystem(root: .caches) - try caches.configureForSession() + let caches = try SandboxFilesystem(root: .caches) #expect(await caches.exists(path: "/")) } @Test("sandbox app group invalid id throws unsupported") func sandboxAppGroupInvalidIDThrowsUnsupported() { - let fs = SandboxFilesystem(root: .appGroup("invalid.group.\(UUID().uuidString)")) do { - try fs.configureForSession() + _ = try SandboxFilesystem(root: .appGroup("invalid.group.\(UUID().uuidString)")) Issue.record("expected unsupported error") - } catch let error as ShellError { + } catch let error as WorkspaceError { #expect(error.description.contains("app group")) } catch { Issue.record("unexpected error: \(error)") @@ -188,12 +210,11 @@ struct FilesystemOptionsTests { @Test("security-scoped filesystem unsupported on tvOS/watchOS") func securityScopedFilesystemUnsupportedOnUnsupportedPlatforms() throws { let tempURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - let fs = try SecurityScopedFilesystem(url: tempURL) do { - try fs.configureForSession() + _ = try SecurityScopedFilesystem(url: tempURL) Issue.record("expected unsupported error") - } catch let error as ShellError { + } catch let error as WorkspaceError { #expect(error.description.contains("security-scoped URLs not supported")) } catch { Issue.record("unexpected error: \(error)") @@ -206,7 +227,6 @@ struct FilesystemOptionsTests { defer { TestSupport.removeDirectory(root) } let readWriteFS = try SecurityScopedFilesystem(url: root, mode: .readWrite) - try readWriteFS.configureForSession() try await readWriteFS.writeFile(path: "/note.txt", data: Data("hello".utf8), append: false) let bookmarkData = try readWriteFS.makeBookmarkData() @@ -219,17 +239,15 @@ struct FilesystemOptionsTests { try await readWriteFS.saveBookmark(id: bookmarkID, store: store) let restored = try await SecurityScopedFilesystem.loadBookmark(id: bookmarkID, store: store, mode: .readWrite) - try restored.configureForSession() let data = try await restored.readFile(path: "/note.txt") #expect(String(decoding: data, as: UTF8.self) == "hello") let readOnly = try SecurityScopedFilesystem(bookmarkData: bookmarkData, mode: .readOnly) - try readOnly.configureForSession() do { try await readOnly.writeFile(path: "/blocked.txt", data: Data("x".utf8), append: false) Issue.record("expected read-only rejection") - } catch let error as ShellError { + } catch let error as WorkspaceError { #expect(error.description.contains("filesystem is read-only")) } catch { Issue.record("unexpected error: \(error)") diff --git a/Tests/BashTests/ParserAndFilesystemTests.swift b/Tests/BashTests/ParserAndFilesystemTests.swift index 432cdc2..3b9340f 100644 --- a/Tests/BashTests/ParserAndFilesystemTests.swift +++ b/Tests/BashTests/ParserAndFilesystemTests.swift @@ -185,7 +185,6 @@ struct ParserAndFilesystemTests { @Test("filesystems reject paths with null bytes") func filesystemsRejectPathsWithNullBytes() async throws { let inMemory = InMemoryFilesystem() - try inMemory.configureForSession() do { _ = try await inMemory.readFile(path: "/bad\u{0}name") diff --git a/Tests/BashTests/SessionIntegrationTests.swift b/Tests/BashTests/SessionIntegrationTests.swift index e142cb8..672a638 100644 --- a/Tests/BashTests/SessionIntegrationTests.swift +++ b/Tests/BashTests/SessionIntegrationTests.swift @@ -1569,6 +1569,8 @@ struct SessionIntegrationTests { case let .network(network): #expect(network.url == "http://127.0.0.1:1") #expect(network.method == "GET") + case .filesystem: + Issue.record("expected network permission request") } } @@ -1636,6 +1638,186 @@ struct SessionIntegrationTests { #expect(requests.isEmpty) } + @Test("filesystem permission handler can deny reads") + func filesystemPermissionHandlerCanDenyReads() async throws { + let probe = PermissionProbe() + let (session, root) = try await TestSupport.makeSession( + permissionHandler: { request in + await probe.record(request) + switch request.kind { + case .filesystem: + return .deny(message: "filesystem access denied") + case .network: + return .allow + } + } + ) + defer { TestSupport.removeDirectory(root) } + + let noteURL = root + .appendingPathComponent("home/user", isDirectory: true) + .appendingPathComponent("note.txt") + try Data("hello\n".utf8).write(to: noteURL) + + let result = await session.run("cat ./folder/../note.txt") + #expect(result.exitCode == 1) + #expect(result.stderrString == "./folder/../note.txt: filesystem access denied\n") + + let requests = await probe.snapshot() + #expect(requests.count == 1) + #expect(requests[0].command == "cat") + switch requests[0].kind { + case let .filesystem(filesystem): + #expect(filesystem.operation == .readFile) + #expect(filesystem.path == "/home/user/note.txt") + #expect(filesystem.sourcePath == nil) + #expect(filesystem.destinationPath == nil) + #expect(!filesystem.append) + #expect(!filesystem.recursive) + case .network: + Issue.record("expected filesystem permission request") + } + } + + @Test("filesystem permission handler can deny shell redirection writes without mutating") + func filesystemPermissionHandlerCanDenyShellRedirectionWritesWithoutMutating() async throws { + let probe = PermissionProbe() + let (session, root) = try await TestSupport.makeSession( + permissionHandler: { request in + await probe.record(request) + switch request.kind { + case .filesystem: + return .deny(message: "filesystem write denied") + case .network: + return .allow + } + } + ) + defer { TestSupport.removeDirectory(root) } + + let result = await session.run("echo blocked > ./dir/../blocked.txt") + #expect(result.exitCode == 1) + #expect(result.stderrString.contains("filesystem write denied")) + + let blockedURL = root + .appendingPathComponent("home/user", isDirectory: true) + .appendingPathComponent("blocked.txt") + #expect(!FileManager.default.fileExists(atPath: blockedURL.path)) + + let requests = await probe.snapshot() + #expect(requests.count == 1) + #expect(requests[0].command == "echo") + switch requests[0].kind { + case let .filesystem(filesystem): + #expect(filesystem.operation == .writeFile) + #expect(filesystem.path == "/home/user/blocked.txt") + #expect(!filesystem.append) + case .network: + Issue.record("expected filesystem permission request") + } + } + + @Test("filesystem permission handler allow once does not persist") + func filesystemPermissionHandlerAllowOnceDoesNotPersist() async throws { + let probe = PermissionProbe() + let (session, root) = try await TestSupport.makeSession( + permissionHandler: { request in + await probe.record(request) + switch request.kind { + case .filesystem: + return .allow + case .network: + return .allow + } + } + ) + defer { TestSupport.removeDirectory(root) } + + let noteURL = root + .appendingPathComponent("home/user", isDirectory: true) + .appendingPathComponent("note.txt") + try Data("hello\n".utf8).write(to: noteURL) + + let first = await session.run("cat note.txt") + let second = await session.run("cat note.txt") + + #expect(first.exitCode == 0) + #expect(second.exitCode == 0) + + let requests = await probe.snapshot() + #expect(requests.count == 2) + } + + @Test("filesystem permission handler can allow for session") + func filesystemPermissionHandlerCanAllowForSession() async throws { + let probe = PermissionProbe() + let (session, root) = try await TestSupport.makeSession( + permissionHandler: { request in + await probe.record(request) + switch request.kind { + case .filesystem: + return .allowForSession + case .network: + return .allow + } + } + ) + defer { TestSupport.removeDirectory(root) } + + let noteURL = root + .appendingPathComponent("home/user", isDirectory: true) + .appendingPathComponent("note.txt") + try Data("hello\n".utf8).write(to: noteURL) + + let first = await session.run("cat note.txt") + let second = await session.run("cat note.txt") + + #expect(first.exitCode == 0) + #expect(second.exitCode == 0) + + let requests = await probe.snapshot() + #expect(requests.count == 1) + } + + @Test("filesystem permission request captures copy source and destination") + func filesystemPermissionRequestCapturesCopySourceAndDestination() async throws { + let probe = PermissionProbe() + let (session, root) = try await TestSupport.makeSession( + permissionHandler: { request in + await probe.record(request) + switch request.kind { + case .filesystem: + return .deny(message: "copy denied") + case .network: + return .allow + } + } + ) + defer { TestSupport.removeDirectory(root) } + + let sourceURL = root + .appendingPathComponent("home/user", isDirectory: true) + .appendingPathComponent("source.txt") + try Data("copy me\n".utf8).write(to: sourceURL) + + let result = await session.run("cp ./source.txt ./dir/../copy.txt") + #expect(result.exitCode == 1) + #expect(result.stderrString.contains("copy denied")) + + let requests = await probe.snapshot() + #expect(requests.count == 1) + #expect(requests[0].command == "cp") + switch requests[0].kind { + case let .filesystem(filesystem): + #expect(filesystem.operation == .copy) + #expect(filesystem.sourcePath == "/home/user/source.txt") + #expect(filesystem.destinationPath == "/home/user/copy.txt") + #expect(!filesystem.recursive) + case .network: + Issue.record("expected filesystem permission request") + } + } + @Test("curl network policy can deny private ranges") func curlNetworkPolicyCanDenyPrivateRanges() async throws { let (session, root) = try await TestSupport.makeSession( @@ -1886,4 +2068,59 @@ struct SessionIntegrationTimeoutTests { #expect(result.stderrString.contains("blocked after approval wait")) #expect(!result.stderrString.contains("execution timed out")) } + + @Test("timeout excludes filesystem permission wait time") + func timeoutExcludesFilesystemPermissionWaitTime() async throws { + let (session, root) = try await TestSupport.makeSession( + permissionHandler: { request in + guard case .filesystem = request.kind else { + return .allow + } + + try? await Task.sleep(nanoseconds: 1_000_000_000) + return .deny(message: "blocked after approval wait") + } + ) + defer { TestSupport.removeDirectory(root) } + + let noteURL = root + .appendingPathComponent("home/user", isDirectory: true) + .appendingPathComponent("note.txt") + try Data("hello\n".utf8).write(to: noteURL) + + let result = await session.run("timeout 0.5 cat note.txt") + #expect(result.exitCode == 1) + #expect(result.stderrString.contains("blocked after approval wait")) + #expect(!result.stderrString.contains("timed out")) + } + + @Test("wall clock limits exclude filesystem permission wait time") + func wallClockLimitsExcludeFilesystemPermissionWaitTime() async throws { + let (session, root) = try await TestSupport.makeSession( + permissionHandler: { request in + guard case .filesystem = request.kind else { + return .allow + } + + try? await Task.sleep(nanoseconds: 1_000_000_000) + return .deny(message: "blocked after approval wait") + } + ) + defer { TestSupport.removeDirectory(root) } + + let noteURL = root + .appendingPathComponent("home/user", isDirectory: true) + .appendingPathComponent("note.txt") + try Data("hello\n".utf8).write(to: noteURL) + + let result = await session.run( + "cat note.txt", + options: RunOptions( + executionLimits: ExecutionLimits(maxWallClockDuration: 0.5) + ) + ) + #expect(result.exitCode == 1) + #expect(result.stderrString.contains("blocked after approval wait")) + #expect(!result.stderrString.contains("execution timed out")) + } } From c0e06eed6757f659467adb27f79df52d7c93b86d Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 22 Mar 2026 21:08:47 -0700 Subject: [PATCH 09/14] Started using Workspace 0.2.0 --- Package.resolved | 6 +- Package.swift | 2 +- Sources/Bash/BashSession.swift | 2 +- .../Support/PermissionedShellFilesystem.swift | 4 +- Sources/Bash/WorkspaceCompat.swift | 282 +++++++++++++++++- Tests/BashTests/FilesystemOptionsTests.swift | 8 +- 6 files changed, 291 insertions(+), 13 deletions(-) diff --git a/Package.resolved b/Package.resolved index 10bd8c7..09dcdfa 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ec694d34f76f14a85744579ce0dd200e5e16b3548f722460ba3542b0fa505ada", + "originHash" : "67d3a10de52cc088d55e494edf0ac4061befdc4fd1cbd416594819f8a140a7b7", "pins" : [ { "identity" : "swift-argument-parser", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/velos/Workspace.git", "state" : { - "revision" : "752bb5ef27f08ce6d97e6a3c2537a7358ae2635e", - "version" : "0.1.0" + "revision" : "f68074337d57acdf1b2b52ed457c15dc68184e24", + "version" : "0.2.0" } }, { diff --git a/Package.swift b/Package.swift index 0329002..b938028 100644 --- a/Package.swift +++ b/Package.swift @@ -33,7 +33,7 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/velos/Workspace.git", from: "0.1.0"), + .package(url: "https://github.com/velos/Workspace.git", from: "0.2.0"), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), .package(url: "https://github.com/jpsim/Yams", from: "5.1.3"), ], diff --git a/Sources/Bash/BashSession.swift b/Sources/Bash/BashSession.swift index f125101..9cc0540 100644 --- a/Sources/Bash/BashSession.swift +++ b/Sources/Bash/BashSession.swift @@ -24,7 +24,7 @@ public final actor BashSession { public init(rootDirectory: URL, options: SessionOptions = .init()) async throws { let filesystem = options.filesystem - try filesystem.configure(rootDirectory: rootDirectory) + try await filesystem.configure(rootDirectory: rootDirectory) try await self.init(options: options, configuredFilesystem: filesystem) } diff --git a/Sources/Bash/Support/PermissionedShellFilesystem.swift b/Sources/Bash/Support/PermissionedShellFilesystem.swift index f48c874..ac1c0b1 100644 --- a/Sources/Bash/Support/PermissionedShellFilesystem.swift +++ b/Sources/Bash/Support/PermissionedShellFilesystem.swift @@ -25,8 +25,8 @@ final class PermissionedShellFilesystem: ShellFilesystem, @unchecked Sendable { return filesystem } - func configure(rootDirectory: URL) throws { - try base.configure(rootDirectory: rootDirectory) + func configure(rootDirectory: URL) async throws { + try await base.configure(rootDirectory: rootDirectory) } func stat(path: String) async throws -> FileInfo { diff --git a/Sources/Bash/WorkspaceCompat.swift b/Sources/Bash/WorkspaceCompat.swift index f6755fd..e0f7483 100644 --- a/Sources/Bash/WorkspaceCompat.swift +++ b/Sources/Bash/WorkspaceCompat.swift @@ -1,5 +1,283 @@ @_exported import Workspace +import Foundation -public typealias ShellFilesystem = WorkspaceFilesystem +public typealias WorkspaceFilesystem = ShellFilesystem -typealias PathUtils = WorkspacePath +public struct FileInfo: Sendable, Codable { + public var path: String + public var isDirectory: Bool + public var isSymbolicLink: Bool + public var size: UInt64 + public var permissions: Int + public var modificationDate: Date? + + public init( + path: String, + isDirectory: Bool, + isSymbolicLink: Bool, + size: UInt64, + permissions: Int, + modificationDate: Date? + ) { + self.path = path + self.isDirectory = isDirectory + self.isSymbolicLink = isSymbolicLink + self.size = size + self.permissions = permissions + self.modificationDate = modificationDate + } +} + +public struct DirectoryEntry: Sendable, Codable { + public var name: String + public var info: FileInfo + + public init(name: String, info: FileInfo) { + self.name = name + self.info = info + } +} + +public protocol ShellFilesystem: AnyObject, Sendable { + func configure(rootDirectory: URL) async throws + + func stat(path: String) async throws -> FileInfo + func listDirectory(path: String) async throws -> [DirectoryEntry] + func readFile(path: String) async throws -> Data + func writeFile(path: String, data: Data, append: Bool) async throws + func createDirectory(path: String, recursive: Bool) async throws + func remove(path: String, recursive: Bool) async throws + func move(from sourcePath: String, to destinationPath: String) async throws + func copy(from sourcePath: String, to destinationPath: String, recursive: Bool) async throws + func createSymlink(path: String, target: String) async throws + func createHardLink(path: String, target: String) async throws + func readSymlink(path: String) async throws -> String + func setPermissions(path: String, permissions: Int) async throws + func resolveRealPath(path: String) async throws -> String + + func exists(path: String) async -> Bool + func glob(pattern: String, currentDirectory: String) async throws -> [String] +} + +public extension ShellFilesystem where Self: FileSystem { + func stat(path: String) async throws -> FileInfo { + let info = try await (self as any FileSystem).stat(path: try workspacePath(path)) + return FileInfo( + path: info.path.string, + isDirectory: info.kind == .directory, + isSymbolicLink: info.kind == .symlink, + size: info.size, + permissions: Int(info.permissions.rawValue), + modificationDate: info.modificationDate + ) + } + + func listDirectory(path: String) async throws -> [DirectoryEntry] { + let entries = try await (self as any FileSystem).listDirectory(path: try workspacePath(path)) + return entries.map { entry in + DirectoryEntry( + name: entry.name, + info: FileInfo( + path: entry.info.path.string, + isDirectory: entry.info.kind == .directory, + isSymbolicLink: entry.info.kind == .symlink, + size: entry.info.size, + permissions: Int(entry.info.permissions.rawValue), + modificationDate: entry.info.modificationDate + ) + ) + } + } + + func readFile(path: String) async throws -> Data { + try await (self as any FileSystem).readFile(path: try workspacePath(path)) + } + + func writeFile(path: String, data: Data, append: Bool) async throws { + try await (self as any FileSystem).writeFile(path: try workspacePath(path), data: data, append: append) + } + + func createDirectory(path: String, recursive: Bool) async throws { + try await (self as any FileSystem).createDirectory(path: try workspacePath(path), recursive: recursive) + } + + func remove(path: String, recursive: Bool) async throws { + try await (self as any FileSystem).remove(path: try workspacePath(path), recursive: recursive) + } + + func move(from sourcePath: String, to destinationPath: String) async throws { + try await (self as any FileSystem).move( + from: try workspacePath(sourcePath), + to: try workspacePath(destinationPath) + ) + } + + func copy(from sourcePath: String, to destinationPath: String, recursive: Bool) async throws { + try await (self as any FileSystem).copy( + from: try workspacePath(sourcePath), + to: try workspacePath(destinationPath), + recursive: recursive + ) + } + + func createSymlink(path: String, target: String) async throws { + try await (self as any FileSystem).createSymlink(path: try workspacePath(path), target: target) + } + + func createHardLink(path: String, target: String) async throws { + try await (self as any FileSystem).createHardLink( + path: try workspacePath(path), + target: try workspacePath(target) + ) + } + + func readSymlink(path: String) async throws -> String { + try await (self as any FileSystem).readSymlink(path: try workspacePath(path)) + } + + func setPermissions(path: String, permissions: Int) async throws { + try await (self as any FileSystem).setPermissions( + path: try workspacePath(path), + permissions: POSIXPermissions(permissions) + ) + } + + func resolveRealPath(path: String) async throws -> String { + let realPath = try await (self as any FileSystem).resolveRealPath(path: try workspacePath(path)) + return realPath.string + } + + func exists(path: String) async -> Bool { + do { + return await (self as any FileSystem).exists(path: try workspacePath(path)) + } catch { + return false + } + } + + func glob(pattern: String, currentDirectory: String) async throws -> [String] { + let matches = try await (self as any FileSystem).glob( + pattern: pattern, + currentDirectory: try workspacePath(currentDirectory) + ) + return matches.map(\.string) + } +} + +extension ReadWriteFilesystem: ShellFilesystem {} +extension InMemoryFilesystem: ShellFilesystem {} +extension MountableFilesystem: ShellFilesystem {} +extension OverlayFilesystem: ShellFilesystem {} +extension SandboxFilesystem: ShellFilesystem {} +extension SecurityScopedFilesystem: ShellFilesystem {} +extension PermissionedFileSystem: ShellFilesystem {} + +enum PathUtils { + static func validate(_ path: String) throws { + if path.contains("\u{0}") { + throw ShellError.invalidPath(path) + } + } + + static func normalize(path: String, currentDirectory: String) -> String { + if path.isEmpty { + return currentDirectory + } + + let base: [String] + if path.hasPrefix("/") { + base = [] + } else { + base = splitComponents(currentDirectory) + } + + var parts = base + for piece in path.split(separator: "/", omittingEmptySubsequences: true) { + switch piece { + case ".": + continue + case "..": + if !parts.isEmpty { + parts.removeLast() + } + default: + parts.append(String(piece)) + } + } + + return "/" + parts.joined(separator: "/") + } + + static func splitComponents(_ absolutePath: String) -> [String] { + absolutePath.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + } + + static func basename(_ path: String) -> String { + let normalized = path == "/" ? "/" : path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if normalized == "/" || normalized.isEmpty { + return "/" + } + return normalized.split(separator: "/").last.map(String.init) ?? "/" + } + + static func dirname(_ path: String) -> String { + let normalized = normalize(path: path, currentDirectory: "/") + if normalized == "/" { + return "/" + } + + var parts = splitComponents(normalized) + _ = parts.popLast() + if parts.isEmpty { + return "/" + } + return "/" + parts.joined(separator: "/") + } + + static func join(_ lhs: String, _ rhs: String) -> String { + if rhs.hasPrefix("/") { + return normalize(path: rhs, currentDirectory: "/") + } + + let separator = lhs.hasSuffix("/") ? "" : "/" + return normalize(path: lhs + separator + rhs, currentDirectory: "/") + } + + static func containsGlob(_ token: String) -> Bool { + token.contains("*") || token.contains("?") || token.contains("[") + } + + static func globToRegex(_ pattern: String) -> String { + var regex = "^" + var index = pattern.startIndex + + while index < pattern.endIndex { + let char = pattern[index] + if char == "*" { + regex += ".*" + } else if char == "?" { + regex += "." + } else if char == "[" { + if let closeIndex = pattern[index...].firstIndex(of: "]") { + let range = pattern.index(after: index).. WorkspacePath { + try PathUtils.validate(path) + let normalized = PathUtils.normalize(path: path, currentDirectory: currentDirectory) + return try WorkspacePath(validating: normalized) +} diff --git a/Tests/BashTests/FilesystemOptionsTests.swift b/Tests/BashTests/FilesystemOptionsTests.swift index 981cc7a..8baf0d4 100644 --- a/Tests/BashTests/FilesystemOptionsTests.swift +++ b/Tests/BashTests/FilesystemOptionsTests.swift @@ -30,7 +30,7 @@ struct FilesystemOptionsTests { let shellFilesystem: any ShellFilesystem = workspaceFilesystem let inMemoryFilesystem = InMemoryFilesystem() try await inMemoryFilesystem.writeFile(path: "/note.txt", data: Data("shim".utf8), append: false) - inMemoryFilesystem.reset() + await inMemoryFilesystem.reset() let info = FileInfo( path: "/note.txt", @@ -59,7 +59,7 @@ struct FilesystemOptionsTests { let session = try await BashSession( options: SessionOptions( - filesystem: try OverlayFilesystem(rootDirectory: root), + filesystem: try await OverlayFilesystem(rootDirectory: root), layout: .rootOnly ) ) @@ -94,11 +94,11 @@ struct FilesystemOptionsTests { mounts: [ MountableFilesystem.Mount( mountPoint: "/workspace", - filesystem: try OverlayFilesystem(rootDirectory: workspaceRoot) + filesystem: try await OverlayFilesystem(rootDirectory: workspaceRoot) ), MountableFilesystem.Mount( mountPoint: "/docs", - filesystem: try OverlayFilesystem(rootDirectory: docsRoot) + filesystem: try await OverlayFilesystem(rootDirectory: docsRoot) ), ] ) From a735d1840938ac23900f984a82237510d4a564d1 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 22 Mar 2026 23:16:46 -0700 Subject: [PATCH 10/14] Updated to Workspace 0.2.0 --- Sources/Bash/BashSession+ControlFlow.swift | 26 +- Sources/Bash/BashSession.swift | 18 +- Sources/Bash/Commands/BuiltinCommand.swift | 50 +-- Sources/Bash/Commands/CommandSupport.swift | 10 +- .../Bash/Commands/CompressionCommands.swift | 75 ++-- .../Commands/File/BasicFileCommands.swift | 3 +- .../Commands/File/DirectoryCommands.swift | 5 +- .../Bash/Commands/File/MetadataCommands.swift | 16 +- Sources/Bash/Commands/File/TreeCommand.swift | 9 +- .../Bash/Commands/NavigationCommands.swift | 72 ++-- Sources/Bash/Commands/NetworkCommands.swift | 8 +- Sources/Bash/Commands/Text/DiffCommand.swift | 19 +- .../Bash/Commands/Text/SearchCommands.swift | 42 ++- Sources/Bash/Commands/UtilityCommands.swift | 15 +- Sources/Bash/Core/ShellExecutor.swift | 74 ++-- Sources/Bash/Core/ShellLexer.swift | 2 +- .../Support/PermissionedShellFilesystem.swift | 331 ------------------ Sources/Bash/Support/Permissions.swift | 60 ++-- .../Support/ShellPermissionedFileSystem.swift | 298 ++++++++++++++++ Sources/Bash/Support/Types.swift | 12 +- Sources/Bash/WorkspaceCompat.swift | 283 --------------- Sources/Bash/WorkspaceSupport.swift | 45 +++ Sources/BashGit/GitEngine.swift | 150 +++----- Sources/BashPython/CPythonRuntime.swift | 62 +--- Sources/BashPython/PythonRuntime.swift | 8 +- Tests/BashGitTests/GitCommandTests.swift | 4 +- Tests/BashGitTests/TestSupport.swift | 8 +- .../CPythonRuntimeIntegrationTests.swift | 4 +- Tests/BashPythonTests/TestSupport.swift | 10 +- Tests/BashSecretsTests/TestSupport.swift | 2 +- Tests/BashTests/FilesystemOptionsTests.swift | 31 +- .../BashTests/ParserAndFilesystemTests.swift | 21 +- Tests/BashTests/SessionIntegrationTests.swift | 12 +- Tests/BashTests/TestSupport.swift | 6 +- 34 files changed, 732 insertions(+), 1059 deletions(-) delete mode 100644 Sources/Bash/Support/PermissionedShellFilesystem.swift create mode 100644 Sources/Bash/Support/ShellPermissionedFileSystem.swift delete mode 100644 Sources/Bash/WorkspaceCompat.swift create mode 100644 Sources/Bash/WorkspaceSupport.swift diff --git a/Sources/Bash/BashSession+ControlFlow.swift b/Sources/Bash/BashSession+ControlFlow.swift index 8652b4f..30be990 100644 --- a/Sources/Bash/BashSession+ControlFlow.swift +++ b/Sources/Bash/BashSession+ControlFlow.swift @@ -978,9 +978,9 @@ extension BashSession { exitCode: value.isEmpty ? 0 : 1 ) case "-e", "-f", "-d": - let path = PathUtils.normalize( - path: value, - currentDirectory: currentDirectoryStore + let path = WorkspacePath( + normalizing: value, + relativeTo: WorkspacePath(normalizing: currentDirectoryStore) ) guard await filesystemStore.exists(path: path) else { return CommandResult(stdout: Data(), stderr: Data(), exitCode: 1) @@ -1145,9 +1145,9 @@ extension BashSession { targetWord, environment: environmentStore ) - let path = PathUtils.normalize( - path: target, - currentDirectory: currentDirectoryStore + let path = WorkspacePath( + normalizing: target, + relativeTo: WorkspacePath(normalizing: currentDirectoryStore) ) do { try await filesystemStore.writeFile( @@ -1166,9 +1166,9 @@ extension BashSession { targetWord, environment: environmentStore ) - let path = PathUtils.normalize( - path: target, - currentDirectory: currentDirectoryStore + let path = WorkspacePath( + normalizing: target, + relativeTo: WorkspacePath(normalizing: currentDirectoryStore) ) do { try await filesystemStore.writeFile( @@ -1187,9 +1187,9 @@ extension BashSession { targetWord, environment: environmentStore ) - let path = PathUtils.normalize( - path: target, - currentDirectory: currentDirectoryStore + let path = WorkspacePath( + normalizing: target, + relativeTo: WorkspacePath(normalizing: currentDirectoryStore) ) var combined = Data() combined.append(result.stdout) @@ -1519,7 +1519,7 @@ extension BashSession { environment: [String: String] ) -> Bool { let expanded = evaluateCaseWord(rawPattern, environment: environment) - guard let regex = try? NSRegularExpression(pattern: PathUtils.globToRegex(expanded)) else { + guard let regex = try? NSRegularExpression(pattern: WorkspacePath.globToRegex(expanded)) else { return expanded == value } diff --git a/Sources/Bash/BashSession.swift b/Sources/Bash/BashSession.swift index 9cc0540..d96fcbc 100644 --- a/Sources/Bash/BashSession.swift +++ b/Sources/Bash/BashSession.swift @@ -2,10 +2,10 @@ import Foundation import Workspace public final actor BashSession { - let filesystemStore: any ShellFilesystem + let filesystemStore: any FileSystem private let options: SessionOptions let jobManager: ShellJobManager - private let permissionAuthorizer: PermissionAuthorizer + private let permissionAuthorizer: ShellPermissionAuthorizer var executionControlStore: ExecutionControl? var currentDirectoryStore: String @@ -59,7 +59,7 @@ public final actor BashSession { if let overrideDirectory = options.currentDirectory { do { - try PathUtils.validate(overrideDirectory) + try validateWorkspacePath(overrideDirectory) } catch { return CommandResult( stdout: Data(), @@ -74,7 +74,7 @@ public final actor BashSession { } if let overrideDirectory = options.currentDirectory { - currentDirectoryStore = PathUtils.normalize( + currentDirectoryStore = normalizeWorkspacePath( path: overrideDirectory, currentDirectory: savedCurrentDirectory ) @@ -306,7 +306,7 @@ public final actor BashSession { break case .unixLike: for path in ["/home/user", "/bin", "/usr/bin", "/tmp"] { - try await filesystemStore.createDirectory(path: path, recursive: true) + try await filesystemStore.createDirectory(path: WorkspacePath(normalizing: path), recursive: true) } } } @@ -316,25 +316,25 @@ public final actor BashSession { let data = Data(content.utf8) for directory in ["/bin", "/usr/bin"] { - let path = "\(directory)/\(commandName)" + let path = WorkspacePath(normalizing: "\(directory)/\(commandName)") if await filesystemStore.exists(path: path) { continue } do { try await filesystemStore.writeFile(path: path, data: data, append: false) - try await filesystemStore.setPermissions(path: path, permissions: 0o755) + try await filesystemStore.setPermissions(path: path, permissions: POSIXPermissions(0o755)) } catch { // Best effort for command lookup stubs. } } } - private init(options: SessionOptions, configuredFilesystem: any ShellFilesystem) async throws { + private init(options: SessionOptions, configuredFilesystem: any FileSystem) async throws { self.options = options filesystemStore = configuredFilesystem jobManager = ShellJobManager() - permissionAuthorizer = PermissionAuthorizer( + permissionAuthorizer = ShellPermissionAuthorizer( networkPolicy: options.networkPolicy, handler: options.permissionHandler ) diff --git a/Sources/Bash/Commands/BuiltinCommand.swift b/Sources/Bash/Commands/BuiltinCommand.swift index 95a3c86..9c8fb85 100644 --- a/Sources/Bash/Commands/BuiltinCommand.swift +++ b/Sources/Bash/Commands/BuiltinCommand.swift @@ -38,16 +38,16 @@ private actor EffectiveWallClock { } } -private actor PermissionPauseAuthorizer: PermissionAuthorizing { - private let base: any PermissionAuthorizing +private actor PermissionPauseAuthorizer: ShellPermissionAuthorizing { + private let base: any ShellPermissionAuthorizing private let clock: EffectiveWallClock - init(base: any PermissionAuthorizing, clock: EffectiveWallClock) { + init(base: any ShellPermissionAuthorizing, clock: EffectiveWallClock) { self.base = base self.clock = clock } - func authorize(_ request: PermissionRequest) async -> PermissionDecision { + func authorize(_ request: ShellPermissionRequest) async -> ShellPermissionDecision { await clock.beginPause() let decision = await base.authorize(request) await clock.endPause() @@ -58,7 +58,7 @@ private actor PermissionPauseAuthorizer: PermissionAuthorizing { public struct CommandContext: Sendable { public let commandName: String public let arguments: [String] - public let filesystem: any ShellFilesystem + public let filesystem: any FileSystem public let enableGlobbing: Bool public let secretPolicy: SecretHandlingPolicy public let secretResolver: (any SecretReferenceResolving)? @@ -73,13 +73,13 @@ public struct CommandContext: Sendable { public var stderr: Data let secretTracker: SecretExposureTracker? let jobControl: (any ShellJobControlling)? - let permissionAuthorizer: any PermissionAuthorizing + let permissionAuthorizer: any ShellPermissionAuthorizing let executionControl: ExecutionControl? public init( commandName: String, arguments: [String], - filesystem: any ShellFilesystem, + filesystem: any FileSystem, enableGlobbing: Bool, secretPolicy: SecretHandlingPolicy = .off, secretResolver: (any SecretReferenceResolving)? = nil, @@ -109,7 +109,7 @@ public struct CommandContext: Sendable { stderr: stderr, secretTracker: nil, jobControl: nil, - permissionAuthorizer: PermissionAuthorizer(), + permissionAuthorizer: ShellPermissionAuthorizer(), executionControl: nil ) } @@ -117,7 +117,7 @@ public struct CommandContext: Sendable { init( commandName: String, arguments: [String], - filesystem: any ShellFilesystem, + filesystem: any FileSystem, enableGlobbing: Bool, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, @@ -131,7 +131,7 @@ public struct CommandContext: Sendable { stderr: Data = Data(), secretTracker: SecretExposureTracker?, jobControl: (any ShellJobControlling)? = nil, - permissionAuthorizer: any PermissionAuthorizing = PermissionAuthorizer(), + permissionAuthorizer: any ShellPermissionAuthorizing = ShellPermissionAuthorizer(), executionControl: ExecutionControl? = nil ) { self.commandName = commandName @@ -162,8 +162,12 @@ public struct CommandContext: Sendable { stderr.append(Data(string.utf8)) } - public func resolvePath(_ path: String) -> String { - PathUtils.normalize(path: path, currentDirectory: currentDirectory) + public var currentDirectoryPath: WorkspacePath { + WorkspacePath(normalizing: currentDirectory) + } + + public func resolvePath(_ path: String) -> WorkspacePath { + WorkspacePath(normalizing: path, relativeTo: currentDirectoryPath) } public func environmentValue(_ key: String) -> String { @@ -231,8 +235,8 @@ public struct CommandContext: Sendable { } public func requestPermission( - _ request: PermissionRequest - ) async -> PermissionDecision { + _ request: ShellPermissionRequest + ) async -> ShellPermissionDecision { await authorizePermissionRequest( request, using: permissionAuthorizer, @@ -243,16 +247,16 @@ public struct CommandContext: Sendable { public func requestNetworkPermission( url: String, method: String - ) async -> PermissionDecision { + ) async -> ShellPermissionDecision { await requestPermission( - PermissionRequest( + ShellPermissionRequest( command: commandName, - kind: .network(NetworkPermissionRequest(url: url, method: method)) + kind: .network(ShellNetworkPermissionRequest(url: url, method: method)) ) ) } - public var permissionDelegate: any PermissionAuthorizing { + public var permissionDelegate: any ShellPermissionAuthorizing { permissionAuthorizer } @@ -281,7 +285,7 @@ public struct CommandContext: Sendable { _ argv: [String], stdin: Data? = nil, executionControlOverride: ExecutionControl?, - permissionAuthorizerOverride: (any PermissionAuthorizing)? = nil + permissionAuthorizerOverride: (any ShellPermissionAuthorizing)? = nil ) async -> (result: CommandResult, currentDirectory: String, environment: [String: String]) { guard let commandName = argv.first else { return (CommandResult(stdout: Data(), stderr: Data(), exitCode: 0), currentDirectory, environment) @@ -299,8 +303,8 @@ public struct CommandContext: Sendable { let commandArgs = Array(argv.dropFirst()) let effectiveExecutionControl = executionControlOverride ?? executionControl let effectivePermissionAuthorizer = permissionAuthorizerOverride ?? permissionAuthorizer - let childFilesystem = PermissionedShellFilesystem( - base: PermissionedShellFilesystem.unwrap(filesystem), + let childFilesystem = ShellPermissionedFileSystem( + base: ShellPermissionedFileSystem.unwrap(filesystem), commandName: commandName, permissionAuthorizer: effectivePermissionAuthorizer, executionControl: effectiveExecutionControl @@ -414,7 +418,7 @@ public struct CommandContext: Sendable { private func resolveCommand(named commandName: String) -> AnyBuiltinCommand? { if commandName.hasPrefix("/") { - return commandRegistry[PathUtils.basename(commandName)] + return commandRegistry[WorkspacePath.basename(commandName)] } if let direct = commandRegistry[commandName] { @@ -422,7 +426,7 @@ public struct CommandContext: Sendable { } if commandName.contains("/") { - return commandRegistry[PathUtils.basename(commandName)] + return commandRegistry[WorkspacePath.basename(commandName)] } return nil diff --git a/Sources/Bash/Commands/CommandSupport.swift b/Sources/Bash/Commands/CommandSupport.swift index fea7e38..c764244 100644 --- a/Sources/Bash/Commands/CommandSupport.swift +++ b/Sources/Bash/Commands/CommandSupport.swift @@ -50,7 +50,7 @@ enum CommandFS { return (contents, failed) } - static func recursiveSize(of path: String, filesystem: any ShellFilesystem) async throws -> UInt64 { + static func recursiveSize(of path: WorkspacePath, filesystem: any FileSystem) async throws -> UInt64 { let info = try await filesystem.stat(path: path) if !info.isDirectory { return info.size @@ -59,12 +59,12 @@ enum CommandFS { var total: UInt64 = 0 let children = try await filesystem.listDirectory(path: path) for child in children { - total += try await recursiveSize(of: PathUtils.join(path, child.name), filesystem: filesystem) + total += try await recursiveSize(of: path.appending(child.name), filesystem: filesystem) } return total } - static func walk(path: String, filesystem: any ShellFilesystem) async throws -> [String] { + static func walk(path: WorkspacePath, filesystem: any FileSystem) async throws -> [WorkspacePath] { var output = [path] let info = try await filesystem.stat(path: path) guard info.isDirectory else { @@ -73,7 +73,7 @@ enum CommandFS { let children = try await filesystem.listDirectory(path: path) for child in children { - let childPath = PathUtils.join(path, child.name) + let childPath = path.appending(child.name) output.append(contentsOf: try await walk(path: childPath, filesystem: filesystem)) } return output @@ -103,7 +103,7 @@ enum CommandFS { } static func wildcardMatch(pattern: String, value: String) -> Bool { - let regexString = PathUtils.globToRegex(pattern) + let regexString = WorkspacePath.globToRegex(pattern) guard let regex = try? NSRegularExpression(pattern: regexString) else { return false } diff --git a/Sources/Bash/Commands/CompressionCommands.swift b/Sources/Bash/Commands/CompressionCommands.swift index 9402e45..b6ca362 100644 --- a/Sources/Bash/Commands/CompressionCommands.swift +++ b/Sources/Bash/Commands/CompressionCommands.swift @@ -158,10 +158,10 @@ struct ZipCommand: BuiltinCommand { } private static func collectEntries( - virtualPath: String, + virtualPath: WorkspacePath, archivePath: String, recursiveDirectories: Bool, - filesystem: any ShellFilesystem, + filesystem: any FileSystem, seenPaths: inout Set ) async throws -> [ZipCodec.Entry] { let info = try await filesystem.stat(path: virtualPath) @@ -178,7 +178,7 @@ struct ZipCommand: BuiltinCommand { output.append( .directory( path: directoryPath, - mode: info.permissions, + mode: info.permissionBits, modificationTime: modificationTime(info.modificationDate) ) ) @@ -186,7 +186,7 @@ struct ZipCommand: BuiltinCommand { let children = try await filesystem.listDirectory(path: virtualPath).sorted { $0.name < $1.name } for child in children { - let childVirtualPath = PathUtils.join(virtualPath, child.name) + let childVirtualPath = virtualPath.appending(child.name) let childArchivePath = directoryPath + child.name output.append( contentsOf: try await collectEntries( @@ -207,7 +207,7 @@ struct ZipCommand: BuiltinCommand { .file( path: cleanPath, data: data, - mode: info.permissions, + mode: info.permissionBits, modificationTime: modificationTime(info.modificationDate) ) ] @@ -215,11 +215,11 @@ struct ZipCommand: BuiltinCommand { return [] } - private static func archivePathForOperand(_ operand: String, resolvedPath: String) -> String { - let normalizedOperand = PathUtils.normalize(path: operand, currentDirectory: "/") + private static func archivePathForOperand(_ operand: String, resolvedPath: WorkspacePath) -> String { + let normalizedOperand = normalizeWorkspacePath(path: operand, currentDirectory: "/") var archivePath = String(normalizedOperand.dropFirst()) if archivePath.isEmpty { - archivePath = PathUtils.basename(resolvedPath) + archivePath = resolvedPath.basename } if archivePath.isEmpty { archivePath = "root" @@ -294,7 +294,7 @@ struct UnzipCommand: BuiltinCommand { return 0 } - let destinationRoot = options.d.map(context.resolvePath) ?? context.currentDirectory + let destinationRoot = options.d.map(context.resolvePath) ?? context.currentDirectoryPath do { try await context.filesystem.createDirectory(path: destinationRoot, recursive: true) } catch { @@ -304,23 +304,23 @@ struct UnzipCommand: BuiltinCommand { var failed = false for entry in selectedEntries { - let outputPath = PathUtils.normalize(path: entry.path, currentDirectory: destinationRoot) + let outputPath = WorkspacePath(normalizing: entry.path, relativeTo: destinationRoot) do { switch entry.kind { case .directory: try await context.filesystem.createDirectory(path: outputPath, recursive: true) - try? await context.filesystem.setPermissions(path: outputPath, permissions: entry.mode) + try? await context.filesystem.setPermissions(path: outputPath, permissions: POSIXPermissions(entry.mode)) case let .file(data): - let parent = PathUtils.dirname(outputPath) + let parent = outputPath.dirname try await context.filesystem.createDirectory(path: parent, recursive: true) if !options.o, await context.filesystem.exists(path: outputPath) { - context.writeStderr("unzip: \(PathUtils.basename(outputPath)): already exists\n") + context.writeStderr("unzip: \(outputPath.basename): already exists\n") failed = true continue } try await context.filesystem.writeFile(path: outputPath, data: data, append: false) - try? await context.filesystem.setPermissions(path: outputPath, permissions: entry.mode) + try? await context.filesystem.setPermissions(path: outputPath, permissions: POSIXPermissions(entry.mode)) } } catch { context.writeStderr("unzip: \(entry.path): \(error)\n") @@ -412,13 +412,13 @@ struct TarCommand: BuiltinCommand { return 2 } - let baseDirectory = options.C.map(context.resolvePath) ?? context.currentDirectory + let baseDirectory = options.C.map(context.resolvePath) ?? context.currentDirectoryPath var entries: [TarCodec.Entry] = [] var seen = Set() for operand in options.paths { - let resolvedInputPath = PathUtils.normalize(path: operand, currentDirectory: baseDirectory) + let resolvedInputPath = WorkspacePath(normalizing: operand, relativeTo: baseDirectory) let archivePath = archivePathForOperand(operand, resolvedPath: resolvedInputPath) do { entries.append( @@ -471,20 +471,20 @@ struct TarCommand: BuiltinCommand { ) async -> Int32 { do { let entries = try await readTarEntries(context: &context, archiveArg: archiveArg, forceGzip: options.z) - let destinationRoot = options.C.map(context.resolvePath) ?? context.currentDirectory + let destinationRoot = options.C.map(context.resolvePath) ?? context.currentDirectoryPath try await context.filesystem.createDirectory(path: destinationRoot, recursive: true) for entry in filterEntries(entries: entries, filters: options.paths) { - let outputPath = PathUtils.normalize(path: entry.path, currentDirectory: destinationRoot) + let outputPath = WorkspacePath(normalizing: entry.path, relativeTo: destinationRoot) switch entry.kind { case .directory: try await context.filesystem.createDirectory(path: outputPath, recursive: true) - try? await context.filesystem.setPermissions(path: outputPath, permissions: entry.mode) + try? await context.filesystem.setPermissions(path: outputPath, permissions: POSIXPermissions(entry.mode)) case let .file(data): - let parent = PathUtils.dirname(outputPath) + let parent = outputPath.dirname try await context.filesystem.createDirectory(path: parent, recursive: true) try await context.filesystem.writeFile(path: outputPath, data: data, append: false) - try? await context.filesystem.setPermissions(path: outputPath, permissions: entry.mode) + try? await context.filesystem.setPermissions(path: outputPath, permissions: POSIXPermissions(entry.mode)) } } return 0 @@ -535,11 +535,11 @@ struct TarCommand: BuiltinCommand { return value } - private static func archivePathForOperand(_ operand: String, resolvedPath: String) -> String { - let normalizedOperand = PathUtils.normalize(path: operand, currentDirectory: "/") + private static func archivePathForOperand(_ operand: String, resolvedPath: WorkspacePath) -> String { + let normalizedOperand = normalizeWorkspacePath(path: operand, currentDirectory: "/") var archivePath = String(normalizedOperand.dropFirst()) if archivePath.isEmpty { - archivePath = PathUtils.basename(resolvedPath) + archivePath = resolvedPath.basename } if archivePath.isEmpty { archivePath = "root" @@ -548,9 +548,9 @@ struct TarCommand: BuiltinCommand { } private static func collectTarEntries( - virtualPath: String, + virtualPath: WorkspacePath, archivePath: String, - filesystem: any ShellFilesystem, + filesystem: any FileSystem, seenPaths: inout Set ) async throws -> [TarCodec.Entry] { let info = try await filesystem.stat(path: virtualPath) @@ -563,7 +563,7 @@ struct TarCommand: BuiltinCommand { output.append( .directory( path: directoryPath, - mode: info.permissions, + mode: info.permissionBits, modificationTime: modificationTime(info.modificationDate) ) ) @@ -571,7 +571,7 @@ struct TarCommand: BuiltinCommand { let children = try await filesystem.listDirectory(path: virtualPath).sorted { $0.name < $1.name } for child in children { - let childVirtualPath = PathUtils.join(virtualPath, child.name) + let childVirtualPath = virtualPath.appending(child.name) let childArchivePath = directoryPath + child.name output.append( contentsOf: try await collectTarEntries( @@ -591,7 +591,7 @@ struct TarCommand: BuiltinCommand { .file( path: cleanPath, data: data, - mode: info.permissions, + mode: info.permissionBits, modificationTime: modificationTime(info.modificationDate) ) ] @@ -635,7 +635,7 @@ private enum CompressionCommandRunner { continue } - let destinationPath = sourcePath + ".gz" + let destinationPath = WorkspacePath(normalizing: sourcePath.string + ".gz") if !forceOverwrite, await context.filesystem.exists(path: destinationPath) { context.writeStderr("\(commandName): \(file).gz: already exists\n") failed = true @@ -687,7 +687,7 @@ private enum CompressionCommandRunner { let destinationPath = gunzipOutputPath(for: sourcePath) if !forceOverwrite, await context.filesystem.exists(path: destinationPath) { - context.writeStderr("\(commandName): \(PathUtils.basename(destinationPath)): already exists\n") + context.writeStderr("\(commandName): \(destinationPath.basename): already exists\n") failed = true continue } @@ -705,14 +705,15 @@ private enum CompressionCommandRunner { return failed ? 1 : 0 } - private static func gunzipOutputPath(for sourcePath: String) -> String { - if sourcePath.hasSuffix(".tgz") { - return String(sourcePath.dropLast(4)) + ".tar" + private static func gunzipOutputPath(for sourcePath: WorkspacePath) -> WorkspacePath { + let source = sourcePath.string + if source.hasSuffix(".tgz") { + return WorkspacePath(normalizing: String(source.dropLast(4)) + ".tar") } - if sourcePath.hasSuffix(".gz") { - return String(sourcePath.dropLast(3)) + if source.hasSuffix(".gz") { + return WorkspacePath(normalizing: String(source.dropLast(3))) } - return sourcePath + ".out" + return WorkspacePath(normalizing: source + ".out") } } diff --git a/Sources/Bash/Commands/File/BasicFileCommands.swift b/Sources/Bash/Commands/File/BasicFileCommands.swift index b3f55ba..6ee53db 100644 --- a/Sources/Bash/Commands/File/BasicFileCommands.swift +++ b/Sources/Bash/Commands/File/BasicFileCommands.swift @@ -112,7 +112,7 @@ struct StatCommand: BuiltinCommand { context.writeStdout(" File: \(path)\n") context.writeStdout(" Size: \(info.size)\n") context.writeStdout(" Type: \(type)\n") - context.writeStdout(" Mode: \(String(info.permissions, radix: 8))\n") + context.writeStdout(" Mode: \(String(info.permissionBits, radix: 8))\n") } catch { context.writeStderr("stat: \(path): \(error)\n") failed = true @@ -157,4 +157,3 @@ struct TouchCommand: BuiltinCommand { return failed ? 1 : 0 } } - diff --git a/Sources/Bash/Commands/File/DirectoryCommands.swift b/Sources/Bash/Commands/File/DirectoryCommands.swift index fe5f715..b649a67 100644 --- a/Sources/Bash/Commands/File/DirectoryCommands.swift +++ b/Sources/Bash/Commands/File/DirectoryCommands.swift @@ -38,11 +38,11 @@ struct LsCommand: BuiltinCommand { if options.long { for entry in filtered { - let mode = String(entry.info.permissions, radix: 8) + let mode = String(entry.info.permissionBits, radix: 8) context.writeStdout("\(mode) \(entry.info.size) \(entry.name)\n") } } else { - context.writeStdout(filtered.map(\ .name).joined(separator: " ")) + context.writeStdout(filtered.map(\.name).joined(separator: " ")) context.writeStdout("\n") } } else { @@ -116,4 +116,3 @@ struct RmdirCommand: BuiltinCommand { return failed ? 1 : 0 } } - diff --git a/Sources/Bash/Commands/File/MetadataCommands.swift b/Sources/Bash/Commands/File/MetadataCommands.swift index 9a9630b..20fe267 100644 --- a/Sources/Bash/Commands/File/MetadataCommands.swift +++ b/Sources/Bash/Commands/File/MetadataCommands.swift @@ -49,12 +49,12 @@ struct ChmodCommand: BuiltinCommand { private static func applyMode( _ mode: ModeSpec, - to path: String, + to path: WorkspacePath, recursive: Bool, - filesystem: any ShellFilesystem + filesystem: any FileSystem ) async throws { let info = try await filesystem.stat(path: path) - let permissions = try mode.resolve(currentPermissions: info.permissions) + let permissions = try mode.resolve(currentPermissions: info.permissionBits) try await filesystem.setPermissions(path: path, permissions: permissions) guard recursive else { return @@ -68,7 +68,7 @@ struct ChmodCommand: BuiltinCommand { for entry in entries { try await applyMode( mode, - to: PathUtils.join(path, entry.name), + to: path.appending(entry.name), recursive: true, filesystem: filesystem ) @@ -79,14 +79,15 @@ struct ChmodCommand: BuiltinCommand { case absolute(Int) case symbolic([SymbolicOperation]) - func resolve(currentPermissions: Int) throws -> Int { + func resolve(currentPermissions: Int) throws -> POSIXPermissions { switch self { case let .absolute(value): - return value + return POSIXPermissions(value) case let .symbolic(operations): - return operations.reduce(currentPermissions) { partial, operation in + let resolved = operations.reduce(currentPermissions) { partial, operation in operation.apply(to: partial) } + return POSIXPermissions(resolved) } } } @@ -219,4 +220,3 @@ struct FileCommand: BuiltinCommand { return failed ? 1 : 0 } } - diff --git a/Sources/Bash/Commands/File/TreeCommand.swift b/Sources/Bash/Commands/File/TreeCommand.swift index 0da2588..b8fbeaf 100644 --- a/Sources/Bash/Commands/File/TreeCommand.swift +++ b/Sources/Bash/Commands/File/TreeCommand.swift @@ -29,7 +29,7 @@ struct TreeCommand: BuiltinCommand { } else if options.path == "/" || resolved == "/" { displayName = "/" } else { - displayName = PathUtils.basename(options.path) + displayName = WorkspacePath.basename(options.path) } do { @@ -51,12 +51,12 @@ struct TreeCommand: BuiltinCommand { } private static func collectLines( - path: String, + path: WorkspacePath, displayName: String, depth: Int, maxDepth: Int?, includeHidden: Bool, - filesystem: any ShellFilesystem + filesystem: any FileSystem ) async throws -> [String] { var lines = [String(repeating: " ", count: depth) + displayName] let info = try await filesystem.stat(path: path) @@ -75,7 +75,7 @@ struct TreeCommand: BuiltinCommand { for child in children { lines.append( contentsOf: try await collectLines( - path: PathUtils.join(path, child.name), + path: path.appending(child.name), displayName: child.name, depth: depth + 1, maxDepth: maxDepth, @@ -88,4 +88,3 @@ struct TreeCommand: BuiltinCommand { return lines } } - diff --git a/Sources/Bash/Commands/NavigationCommands.swift b/Sources/Bash/Commands/NavigationCommands.swift index 6359008..9fade6c 100644 --- a/Sources/Bash/Commands/NavigationCommands.swift +++ b/Sources/Bash/Commands/NavigationCommands.swift @@ -37,7 +37,7 @@ struct BasenameCommand: BuiltinCommand { } for name in names { - var base = PathUtils.basename(name) + var base = WorkspacePath.basename(name) if let suffix, !suffix.isEmpty, base.hasSuffix(suffix) { base.removeLast(suffix.count) } @@ -66,8 +66,8 @@ struct CdCommand: BuiltinCommand { context.writeStderr("cd: not a directory: \(destination)\n") return 1 } - context.currentDirectory = resolved - context.environment["PWD"] = resolved + context.currentDirectory = resolved.string + context.environment["PWD"] = resolved.string return 0 } catch { context.writeStderr("cd: \(destination): \(error)\n") @@ -92,7 +92,7 @@ struct DirnameCommand: BuiltinCommand { } for path in options.paths { - context.writeStdout(PathUtils.dirname(path) + "\n") + context.writeStdout(WorkspacePath.dirname(path).string + "\n") } return 0 @@ -125,7 +125,7 @@ struct DuCommand: BuiltinCommand { let walked = try await CommandFS.walk(path: resolved, filesystem: context.filesystem) for entry in walked { let size = try await CommandFS.recursiveSize(of: entry, filesystem: context.filesystem) - context.writeStdout("\(size)\t\(entry)\n") + context.writeStdout("\(size)\t\(entry.string)\n") } } } catch { @@ -358,7 +358,7 @@ struct FindCommand: BuiltinCommand { private struct FindRuntime { var pendingBatchExecPaths: [Int: [String]] = [:] - var pendingDirectoryDeletes: Set = [] + var pendingDirectoryDeletes: Set = [] var hadError = false } @@ -785,8 +785,8 @@ struct FindCommand: BuiltinCommand { } private static func traverse( - path: String, - rootPath: String, + path: WorkspacePath, + rootPath: WorkspacePath, depth: Int, parsed: ParsedFindInvocation, context: inout CommandContext, @@ -821,7 +821,7 @@ struct FindCommand: BuiltinCommand { shouldPrune = evalResult.shouldPrune if evalResult.matches, parsed.useDefaultPrint { - context.writeStdout("\(path)\n") + context.writeStdout("\(path.string)\n") } } @@ -847,7 +847,7 @@ struct FindCommand: BuiltinCommand { for child in children.sorted(by: { $0.name < $1.name }) { await traverse( - path: PathUtils.join(path, child.name), + path: path.appending(child.name), rootPath: rootPath, depth: depth + 1, parsed: parsed, @@ -859,8 +859,8 @@ struct FindCommand: BuiltinCommand { private static func evaluate( _ expression: FindExpression, - path: String, - rootPath: String, + path: WorkspacePath, + rootPath: WorkspacePath, info: FileInfo, depth: Int, parsed: ParsedFindInvocation, @@ -954,8 +954,8 @@ struct FindCommand: BuiltinCommand { private static func evaluatePredicate( _ predicate: FindPredicate, - path: String, - rootPath: String, + path: WorkspacePath, + rootPath: WorkspacePath, info: FileInfo, depth: Int, parsed: ParsedFindInvocation, @@ -964,19 +964,19 @@ struct FindCommand: BuiltinCommand { ) async -> FindEvalResult { switch predicate { case let .name(pattern, ignoreCase): - let base = PathUtils.basename(path) + let base = path.basename return FindEvalResult( matches: wildcardMatches(pattern: pattern, value: base, ignoreCase: ignoreCase), shouldPrune: false ) case let .path(pattern, ignoreCase): return FindEvalResult( - matches: wildcardMatches(pattern: pattern, value: path, ignoreCase: ignoreCase), + matches: wildcardMatches(pattern: pattern, value: path.string, ignoreCase: ignoreCase), shouldPrune: false ) case let .regex(pattern, ignoreCase): return FindEvalResult( - matches: regexMatches(pattern: pattern, value: path, ignoreCase: ignoreCase), + matches: regexMatches(pattern: pattern, value: path.string, ignoreCase: ignoreCase), shouldPrune: false ) case let .type(type): @@ -1036,7 +1036,7 @@ struct FindCommand: BuiltinCommand { } return FindEvalResult(matches: matches, shouldPrune: false) case let .perm(mode, matchType): - let current = info.permissions & 0o777 + let current = info.permissionBits & 0o777 let target = mode & 0o777 let matches: Bool switch matchType { @@ -1051,10 +1051,10 @@ struct FindCommand: BuiltinCommand { case .prune: return FindEvalResult(matches: true, shouldPrune: true) case .print: - context.writeStdout("\(path)\n") + context.writeStdout("\(path.string)\n") return FindEvalResult(matches: true, shouldPrune: false) case .print0: - context.stdout.append(Data(path.utf8)) + context.stdout.append(Data(path.string.utf8)) context.stdout.append(Data([0])) return FindEvalResult(matches: true, shouldPrune: false) case let .printf(format): @@ -1083,11 +1083,11 @@ struct FindCommand: BuiltinCommand { let action = parsed.execActions[actionIndex] if action.batchMode { - runtime.pendingBatchExecPaths[actionIndex, default: []].append(path) + runtime.pendingBatchExecPaths[actionIndex, default: []].append(path.string) return FindEvalResult(matches: true, shouldPrune: false) } - let argv = expandExecCommandSingle(action.command, path: path) + let argv = expandExecCommandSingle(action.command, path: path.string) let subcommand = await context.runSubcommandIsolated(argv, stdin: Data()) context.stdout.append(subcommand.result.stdout) context.stderr.append(subcommand.result.stderr) @@ -1103,7 +1103,7 @@ struct FindCommand: BuiltinCommand { runtime: inout FindRuntime ) async { let ordered = runtime.pendingDirectoryDeletes.sorted { - PathUtils.splitComponents($0).count > PathUtils.splitComponents($1).count + $0.components.count > $1.components.count } for path in ordered { @@ -1283,8 +1283,8 @@ struct FindCommand: BuiltinCommand { private static func renderPrintf( format: String, - path: String, - rootPath: String, + path: WorkspacePath, + rootPath: WorkspacePath, info: FileInfo, depth: Int ) -> String { @@ -1292,7 +1292,7 @@ struct FindCommand: BuiltinCommand { let chars = Array(unescaped) var output = "" var index = 0 - let mode = String(format: "%03o", info.permissions & 0o777) + let mode = String(format: "%03o", info.permissionBits & 0o777) while index < chars.count { let char = chars[index] @@ -1313,13 +1313,13 @@ struct FindCommand: BuiltinCommand { case "%": output.append("%") case "p": - output += path + output += path.string case "P": output += relativeToRoot(path: path, rootPath: rootPath) case "f": - output += PathUtils.basename(path) + output += path.basename case "h": - output += PathUtils.dirname(path) + output += path.dirname.string case "s": output += String(info.size) case "d": @@ -1336,21 +1336,21 @@ struct FindCommand: BuiltinCommand { return output } - private static func relativeToRoot(path: String, rootPath: String) -> String { + private static func relativeToRoot(path: WorkspacePath, rootPath: WorkspacePath) -> String { if path == rootPath { return "." } - if rootPath == "/" { - return String(path.drop(while: { $0 == "/" })) + if rootPath.isRoot { + return String(path.string.drop(while: { $0 == "/" })) } - let prefix = rootPath + "/" - if path.hasPrefix(prefix) { - return String(path.dropFirst(prefix.count)) + let prefix = rootPath.string + "/" + if path.string.hasPrefix(prefix) { + return String(path.string.dropFirst(prefix.count)) } - return path + return path.string } private static func unescapePrintf(_ input: String) -> String { diff --git a/Sources/Bash/Commands/NetworkCommands.swift b/Sources/Bash/Commands/NetworkCommands.swift index a6741a5..54ec8e3 100644 --- a/Sources/Bash/Commands/NetworkCommands.swift +++ b/Sources/Bash/Commands/NetworkCommands.swift @@ -618,7 +618,7 @@ struct CurlCommand: BuiltinCommand { return .failure(3) } - let filesystemPath = PathUtils.normalize(path: decodedPath, currentDirectory: "/") + let filesystemPath = WorkspacePath(normalizing: decodedPath) do { let data = try await context.filesystem.readFile(path: filesystemPath) let effectiveBody = method == "HEAD" ? Data() : data @@ -632,7 +632,7 @@ struct CurlCommand: BuiltinCommand { ) ) } catch { - context.writeStderr("curl: \(filesystemPath): \(error)\n") + context.writeStderr("curl: \(filesystemPath.string): \(error)\n") return .failure(37) } } @@ -1010,7 +1010,7 @@ struct CurlCommand: BuiltinCommand { return .failure(26) } - let filename = PathUtils.basename(filePath) + let filename = WorkspacePath.basename(filePath) append("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n") append("Content-Type: \(explicitType ?? "application/octet-stream")\r\n\r\n") body.append(fileData) @@ -1374,7 +1374,7 @@ struct WgetCommand: BuiltinCommand { return "index.html" } - let basename = PathUtils.basename(path) + let basename = WorkspacePath.basename(path) if basename.isEmpty || basename == "/" { return "index.html" } diff --git a/Sources/Bash/Commands/Text/DiffCommand.swift b/Sources/Bash/Commands/Text/DiffCommand.swift index eb7b98e..9c9276f 100644 --- a/Sources/Bash/Commands/Text/DiffCommand.swift +++ b/Sources/Bash/Commands/Text/DiffCommand.swift @@ -69,8 +69,8 @@ struct DiffCommand: BuiltinCommand { } private static func compareDirectories( - leftRoot: String, - rightRoot: String, + leftRoot: WorkspacePath, + rightRoot: WorkspacePath, leftLabel: String, rightLabel: String, unified: Bool, @@ -99,8 +99,8 @@ struct DiffCommand: BuiltinCommand { } let fileDifferent = try await compareFiles( - leftPath: PathUtils.join(leftRoot, key), - rightPath: PathUtils.join(rightRoot, key), + leftPath: leftRoot.appending(key), + rightPath: rightRoot.appending(key), leftLabel: "\(leftLabel)/\(key)", rightLabel: "\(rightLabel)/\(key)", unified: unified, @@ -122,14 +122,15 @@ struct DiffCommand: BuiltinCommand { } private static func recursiveEntryMap( - root: String, - filesystem: any ShellFilesystem + root: WorkspacePath, + filesystem: any FileSystem ) async throws -> [String: FileInfo] { let entries = try await CommandFS.walk(path: root, filesystem: filesystem) var map: [String: FileInfo] = [:] for entry in entries where entry != root { let info = try await filesystem.stat(path: entry) - let relative = String(entry.dropFirst(root.count)).trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let relative = String(entry.string.dropFirst(root.string.count)) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) if !relative.isEmpty { map[relative] = info } @@ -138,8 +139,8 @@ struct DiffCommand: BuiltinCommand { } private static func compareFiles( - leftPath: String, - rightPath: String, + leftPath: WorkspacePath, + rightPath: WorkspacePath, leftLabel: String, rightLabel: String, unified: Bool, diff --git a/Sources/Bash/Commands/Text/SearchCommands.swift b/Sources/Bash/Commands/Text/SearchCommands.swift index aacabc1..74c7b49 100644 --- a/Sources/Bash/Commands/Text/SearchCommands.swift +++ b/Sources/Bash/Commands/Text/SearchCommands.swift @@ -269,11 +269,11 @@ struct GrepCommand: BuiltinCommand { guard !entryInfo.isDirectory else { continue } - if seen.insert(entry).inserted { - targets.append(SearchFileTarget(path: entry, displayPath: entry)) + if seen.insert(entry.string).inserted { + targets.append(SearchFileTarget(path: entry, displayPath: entry.string)) } } - } else if seen.insert(resolved).inserted { + } else if seen.insert(resolved.string).inserted { targets.append(SearchFileTarget(path: resolved, displayPath: path)) } } catch { @@ -489,12 +489,12 @@ struct RgCommand: BuiltinCommand { } private struct CandidateFile { - let path: String + let path: WorkspacePath let displayPath: String } private static func searchFile( - path: String, + path: WorkspacePath, displayPath: String, matcher: SearchMatcher, includeLineNumbers: Bool, @@ -578,7 +578,7 @@ struct RgCommand: BuiltinCommand { var hadError = false let globRegexes: [NSRegularExpression] = globs.compactMap { glob in - try? NSRegularExpression(pattern: PathUtils.globToRegex(glob)) + try? NSRegularExpression(pattern: WorkspacePath.globToRegex(glob)) } for root in roots { @@ -592,30 +592,38 @@ struct RgCommand: BuiltinCommand { guard !entryInfo.isDirectory else { continue } - guard includeHidden || !isHidden(path: entry) else { + guard includeHidden || !isHidden(path: entry.string) else { continue } - guard matchesType(path: entry, includeExtensions: includeExtensions, excludeExtensions: excludeExtensions) else { + guard matchesType( + path: entry.string, + includeExtensions: includeExtensions, + excludeExtensions: excludeExtensions + ) else { continue } - guard matchesGlobs(path: entry, globs: globRegexes) else { + guard matchesGlobs(path: entry.string, globs: globRegexes) else { continue } - if seen.insert(entry).inserted { - result.append(CandidateFile(path: entry, displayPath: entry)) + if seen.insert(entry.string).inserted { + result.append(CandidateFile(path: entry, displayPath: entry.string)) } } } else { - guard includeHidden || !isHidden(path: resolved) else { + guard includeHidden || !isHidden(path: resolved.string) else { continue } - guard matchesType(path: resolved, includeExtensions: includeExtensions, excludeExtensions: excludeExtensions) else { + guard matchesType( + path: resolved.string, + includeExtensions: includeExtensions, + excludeExtensions: excludeExtensions + ) else { continue } - guard matchesGlobs(path: resolved, globs: globRegexes) else { + guard matchesGlobs(path: resolved.string, globs: globRegexes) else { continue } - if seen.insert(resolved).inserted { + if seen.insert(resolved.string).inserted { result.append(CandidateFile(path: resolved, displayPath: root)) } } @@ -641,7 +649,7 @@ struct RgCommand: BuiltinCommand { } private static func isHidden(path: String) -> Bool { - PathUtils.splitComponents(path).contains { $0.hasPrefix(".") } + WorkspacePath.splitComponents(path).contains { $0.hasPrefix(".") } } private static func matchesType(path: String, includeExtensions: Set, excludeExtensions: Set) -> Bool { @@ -659,7 +667,7 @@ struct RgCommand: BuiltinCommand { } private struct SearchFileTarget { - let path: String + let path: WorkspacePath let displayPath: String } diff --git a/Sources/Bash/Commands/UtilityCommands.swift b/Sources/Bash/Commands/UtilityCommands.swift index ee4ef8b..5498d74 100644 --- a/Sources/Bash/Commands/UtilityCommands.swift +++ b/Sources/Bash/Commands/UtilityCommands.swift @@ -927,20 +927,23 @@ struct WhichCommand: BuiltinCommand { for name: String, searchPaths: [String], currentDirectory: String, - filesystem: any ShellFilesystem, + filesystem: any FileSystem, includeAll: Bool ) async -> [String] { if name.contains("/") { - let resolved = PathUtils.normalize(path: name, currentDirectory: currentDirectory) - return await filesystem.exists(path: resolved) ? [resolved] : [] + let resolved = WorkspacePath(normalizing: name, relativeTo: WorkspacePath(normalizing: currentDirectory)) + return await filesystem.exists(path: resolved) ? [resolved.string] : [] } var matches: [String] = [] for path in searchPaths { - let normalizedPath = PathUtils.normalize(path: path, currentDirectory: currentDirectory) - let candidate = PathUtils.join(normalizedPath, name) + let normalizedPath = WorkspacePath( + normalizing: path, + relativeTo: WorkspacePath(normalizing: currentDirectory) + ) + let candidate = normalizedPath.appending(name) if await filesystem.exists(path: candidate) { - matches.append(candidate) + matches.append(candidate.string) if !includeAll { break } diff --git a/Sources/Bash/Core/ShellExecutor.swift b/Sources/Bash/Core/ShellExecutor.swift index d6f8d4a..60cbebb 100644 --- a/Sources/Bash/Core/ShellExecutor.swift +++ b/Sources/Bash/Core/ShellExecutor.swift @@ -17,7 +17,7 @@ enum ShellExecutor { static func execute( parsedLine: ParsedLine, stdin: Data, - filesystem: any ShellFilesystem, + filesystem: any FileSystem, currentDirectory: String, environment: [String: String], history: [String], @@ -25,7 +25,7 @@ enum ShellExecutor { shellFunctions: [String: String], enableGlobbing: Bool, jobControl: (any ShellJobControlling)?, - permissionAuthorizer: any PermissionAuthorizing, + permissionAuthorizer: any ShellPermissionAuthorizing, executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, @@ -163,7 +163,7 @@ enum ShellExecutor { private static func executePipeline( commands: [ParsedCommand], initialInput: Data, - filesystem: any ShellFilesystem, + filesystem: any FileSystem, currentDirectory: inout String, environment: inout [String: String], history: [String], @@ -171,7 +171,7 @@ enum ShellExecutor { shellFunctions: [String: String], enableGlobbing: Bool, jobControl: (any ShellJobControlling)?, - permissionAuthorizer: any PermissionAuthorizing, + permissionAuthorizer: any ShellPermissionAuthorizing, executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, @@ -215,7 +215,7 @@ enum ShellExecutor { private static func executeSingleCommand( _ command: ParsedCommand, stdin: Data, - filesystem: any ShellFilesystem, + filesystem: any FileSystem, currentDirectory: inout String, environment: inout [String: String], history: [String], @@ -223,7 +223,7 @@ enum ShellExecutor { shellFunctions: [String: String], enableGlobbing: Bool, jobControl: (any ShellJobControlling)?, - permissionAuthorizer: any PermissionAuthorizing, + permissionAuthorizer: any ShellPermissionAuthorizing, executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, @@ -238,11 +238,11 @@ enum ShellExecutor { ) } - let baseFilesystem = PermissionedShellFilesystem.unwrap(filesystem) + let baseFilesystem = ShellPermissionedFileSystem.unwrap(filesystem) let initialCommandName = command.words.first?.rawValue.isEmpty == false ? command.words.first!.rawValue : "shell" - let expansionFilesystem = PermissionedShellFilesystem( + let expansionFilesystem = ShellPermissionedFileSystem( base: baseFilesystem, commandName: initialCommandName, permissionAuthorizer: permissionAuthorizer, @@ -293,7 +293,7 @@ enum ShellExecutor { do { input = try await expansionFilesystem.readFile( - path: PathUtils.normalize(path: target, currentDirectory: currentDirectory) + path: WorkspacePath(normalizing: target, relativeTo: WorkspacePath(normalizing: currentDirectory)) ) } catch { stderr.append(Data("\(target): \(error)\n".utf8)) @@ -314,7 +314,7 @@ enum ShellExecutor { } let commandArgs = Array(expandedWords.dropFirst()) - let commandFilesystem = PermissionedShellFilesystem( + let commandFilesystem = ShellPermissionedFileSystem( base: baseFilesystem, commandName: commandName, permissionAuthorizer: permissionAuthorizer, @@ -401,7 +401,10 @@ enum ShellExecutor { ) do { - let path = PathUtils.normalize(path: target, currentDirectory: currentDirectory) + let path = WorkspacePath( + normalizing: target, + relativeTo: WorkspacePath(normalizing: currentDirectory) + ) let redactedOutput = await redactForExternalOutput( result.stdout, secretTracker: secretTracker, @@ -428,7 +431,10 @@ enum ShellExecutor { ) do { - let path = PathUtils.normalize(path: target, currentDirectory: currentDirectory) + let path = WorkspacePath( + normalizing: target, + relativeTo: WorkspacePath(normalizing: currentDirectory) + ) let redactedStderr = await redactForExternalOutput( result.stderr, secretTracker: secretTracker, @@ -458,7 +464,10 @@ enum ShellExecutor { ) do { - let path = PathUtils.normalize(path: target, currentDirectory: currentDirectory) + let path = WorkspacePath( + normalizing: target, + relativeTo: WorkspacePath(normalizing: currentDirectory) + ) let redactedStdout = await redactForExternalOutput( result.stdout, secretTracker: secretTracker, @@ -495,7 +504,7 @@ enum ShellExecutor { _ body: String, functionArguments: [String], stdin: Data, - filesystem: any ShellFilesystem, + filesystem: any FileSystem, currentDirectory: inout String, environment: inout [String: String], history: [String], @@ -503,7 +512,7 @@ enum ShellExecutor { shellFunctions: [String: String], enableGlobbing: Bool, jobControl: (any ShellJobControlling)?, - permissionAuthorizer: any PermissionAuthorizing, + permissionAuthorizer: any ShellPermissionAuthorizing, executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, @@ -623,7 +632,7 @@ enum ShellExecutor { private static func resolveCommand(named commandName: String, registry: [String: AnyBuiltinCommand]) -> AnyBuiltinCommand? { if commandName.hasPrefix("/") { - let base = PathUtils.basename(commandName) + let base = WorkspacePath.basename(commandName) return registry[base] } @@ -632,7 +641,7 @@ enum ShellExecutor { } if commandName.contains("/") { - return registry[PathUtils.basename(commandName)] + return registry[WorkspacePath.basename(commandName)] } return nil @@ -804,7 +813,7 @@ enum ShellExecutor { private static func expandWords( _ words: [ShellWord], - filesystem: any ShellFilesystem, + filesystem: any FileSystem, currentDirectory: String, environment: [String: String], enableGlobbing: Bool @@ -827,7 +836,7 @@ enum ShellExecutor { private static func firstExpansion( word: ShellWord, - filesystem: any ShellFilesystem, + filesystem: any FileSystem, currentDirectory: String, environment: [String: String], enableGlobbing: Bool @@ -844,7 +853,7 @@ enum ShellExecutor { private static func expandWord( _ word: ShellWord, - filesystem: any ShellFilesystem, + filesystem: any FileSystem, currentDirectory: String, environment: [String: String], enableGlobbing: Bool @@ -860,13 +869,16 @@ enum ShellExecutor { } } - guard enableGlobbing, word.hasUnquotedWildcard, PathUtils.containsGlob(combined) else { + guard enableGlobbing, word.hasUnquotedWildcard, WorkspacePath.containsGlob(combined) else { return [combined] } do { - let matches = try await filesystem.glob(pattern: combined, currentDirectory: currentDirectory) - return matches.isEmpty ? [combined] : matches + let matches = try await filesystem.glob( + pattern: combined, + currentDirectory: WorkspacePath(normalizing: currentDirectory) + ) + return matches.isEmpty ? [combined] : matches.map(\.string) } catch { return [combined] } @@ -981,14 +993,14 @@ enum ShellExecutor { private static func expandHereDocumentBody( _ hereDocument: HereDocument, - filesystem: any ShellFilesystem, + filesystem: any FileSystem, currentDirectory: String, environment: [String: String], history: [String], commandRegistry: [String: AnyBuiltinCommand], shellFunctions: [String: String], enableGlobbing: Bool, - permissionAuthorizer: any PermissionAuthorizing, + permissionAuthorizer: any ShellPermissionAuthorizing, executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, @@ -1024,14 +1036,14 @@ enum ShellExecutor { private static func expandUnquotedHereDocumentText( _ text: String, - filesystem: any ShellFilesystem, + filesystem: any FileSystem, currentDirectory: String, environment: [String: String], history: [String], commandRegistry: [String: AnyBuiltinCommand], shellFunctions: [String: String], enableGlobbing: Bool, - permissionAuthorizer: any PermissionAuthorizing, + permissionAuthorizer: any ShellPermissionAuthorizing, executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, @@ -1228,14 +1240,14 @@ enum ShellExecutor { private static func expandCommandSubstitutionsInCommandText( _ commandLine: String, - filesystem: any ShellFilesystem, + filesystem: any FileSystem, currentDirectory: String, environment: [String: String], history: [String], commandRegistry: [String: AnyBuiltinCommand], shellFunctions: [String: String], enableGlobbing: Bool, - permissionAuthorizer: any PermissionAuthorizing, + permissionAuthorizer: any ShellPermissionAuthorizing, executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, @@ -1412,14 +1424,14 @@ enum ShellExecutor { private static func evaluateCommandSubstitutionInCommandText( _ command: String, - filesystem: any ShellFilesystem, + filesystem: any FileSystem, currentDirectory: String, environment: [String: String], history: [String], commandRegistry: [String: AnyBuiltinCommand], shellFunctions: [String: String], enableGlobbing: Bool, - permissionAuthorizer: any PermissionAuthorizing, + permissionAuthorizer: any ShellPermissionAuthorizing, executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, diff --git a/Sources/Bash/Core/ShellLexer.swift b/Sources/Bash/Core/ShellLexer.swift index 4a9da8b..1f169e4 100644 --- a/Sources/Bash/Core/ShellLexer.swift +++ b/Sources/Bash/Core/ShellLexer.swift @@ -20,7 +20,7 @@ struct ShellWord: Sendable { var hasUnquotedWildcard: Bool { parts.contains { part in - part.quote == .none && PathUtils.containsGlob(part.text) + part.quote == .none && WorkspacePath.containsGlob(part.text) } } } diff --git a/Sources/Bash/Support/PermissionedShellFilesystem.swift b/Sources/Bash/Support/PermissionedShellFilesystem.swift deleted file mode 100644 index ac1c0b1..0000000 --- a/Sources/Bash/Support/PermissionedShellFilesystem.swift +++ /dev/null @@ -1,331 +0,0 @@ -import Foundation - -final class PermissionedShellFilesystem: ShellFilesystem, @unchecked Sendable { - let base: any ShellFilesystem - private let commandName: String - private let permissionAuthorizer: any PermissionAuthorizing - private let executionControl: ExecutionControl? - - init( - base: any ShellFilesystem, - commandName: String, - permissionAuthorizer: any PermissionAuthorizing, - executionControl: ExecutionControl? - ) { - self.base = Self.unwrap(base) - self.commandName = commandName - self.permissionAuthorizer = permissionAuthorizer - self.executionControl = executionControl - } - - static func unwrap(_ filesystem: any ShellFilesystem) -> any ShellFilesystem { - if let filesystem = filesystem as? PermissionedShellFilesystem { - return filesystem.base - } - return filesystem - } - - func configure(rootDirectory: URL) async throws { - try await base.configure(rootDirectory: rootDirectory) - } - - func stat(path: String) async throws -> FileInfo { - let normalized = try normalizedPath(path) - try await authorize( - .init( - command: commandName, - kind: .filesystem( - FilesystemPermissionRequest( - operation: .stat, - path: normalized - ) - ) - ) - ) - return try await base.stat(path: normalized) - } - - func listDirectory(path: String) async throws -> [DirectoryEntry] { - let normalized = try normalizedPath(path) - try await authorize( - .init( - command: commandName, - kind: .filesystem( - FilesystemPermissionRequest( - operation: .listDirectory, - path: normalized - ) - ) - ) - ) - return try await base.listDirectory(path: normalized) - } - - func readFile(path: String) async throws -> Data { - let normalized = try normalizedPath(path) - try await authorize( - .init( - command: commandName, - kind: .filesystem( - FilesystemPermissionRequest( - operation: .readFile, - path: normalized - ) - ) - ) - ) - return try await base.readFile(path: normalized) - } - - func writeFile(path: String, data: Data, append: Bool) async throws { - let normalized = try normalizedPath(path) - try await authorize( - .init( - command: commandName, - kind: .filesystem( - FilesystemPermissionRequest( - operation: .writeFile, - path: normalized, - append: append - ) - ) - ) - ) - try await base.writeFile(path: normalized, data: data, append: append) - } - - func createDirectory(path: String, recursive: Bool) async throws { - let normalized = try normalizedPath(path) - try await authorize( - .init( - command: commandName, - kind: .filesystem( - FilesystemPermissionRequest( - operation: .createDirectory, - path: normalized, - recursive: recursive - ) - ) - ) - ) - try await base.createDirectory(path: normalized, recursive: recursive) - } - - func remove(path: String, recursive: Bool) async throws { - let normalized = try normalizedPath(path) - try await authorize( - .init( - command: commandName, - kind: .filesystem( - FilesystemPermissionRequest( - operation: .remove, - path: normalized, - recursive: recursive - ) - ) - ) - ) - try await base.remove(path: normalized, recursive: recursive) - } - - func move(from sourcePath: String, to destinationPath: String) async throws { - let normalizedSource = try normalizedPath(sourcePath) - let normalizedDestination = try normalizedPath(destinationPath) - try await authorize( - .init( - command: commandName, - kind: .filesystem( - FilesystemPermissionRequest( - operation: .move, - sourcePath: normalizedSource, - destinationPath: normalizedDestination - ) - ) - ) - ) - try await base.move(from: normalizedSource, to: normalizedDestination) - } - - func copy(from sourcePath: String, to destinationPath: String, recursive: Bool) async throws { - let normalizedSource = try normalizedPath(sourcePath) - let normalizedDestination = try normalizedPath(destinationPath) - try await authorize( - .init( - command: commandName, - kind: .filesystem( - FilesystemPermissionRequest( - operation: .copy, - sourcePath: normalizedSource, - destinationPath: normalizedDestination, - recursive: recursive - ) - ) - ) - ) - try await base.copy(from: normalizedSource, to: normalizedDestination, recursive: recursive) - } - - func createSymlink(path: String, target: String) async throws { - let normalizedPath = try normalizedPath(path) - try PathUtils.validate(target) - let normalizedTarget = PathUtils.normalize( - path: target, - currentDirectory: PathUtils.dirname(normalizedPath) - ) - try await authorize( - .init( - command: commandName, - kind: .filesystem( - FilesystemPermissionRequest( - operation: .createSymlink, - path: normalizedPath, - destinationPath: normalizedTarget - ) - ) - ) - ) - try await base.createSymlink(path: normalizedPath, target: target) - } - - func createHardLink(path: String, target: String) async throws { - let normalizedLinkPath = try normalizedPath(path) - let normalizedTarget = try normalizedPath(target) - try await authorize( - .init( - command: commandName, - kind: .filesystem( - FilesystemPermissionRequest( - operation: .createHardLink, - path: normalizedLinkPath, - destinationPath: normalizedTarget - ) - ) - ) - ) - try await base.createHardLink(path: normalizedLinkPath, target: normalizedTarget) - } - - func readSymlink(path: String) async throws -> String { - let normalized = try normalizedPath(path) - try await authorize( - .init( - command: commandName, - kind: .filesystem( - FilesystemPermissionRequest( - operation: .readSymlink, - path: normalized - ) - ) - ) - ) - return try await base.readSymlink(path: normalized) - } - - func setPermissions(path: String, permissions: Int) async throws { - let normalized = try normalizedPath(path) - try await authorize( - .init( - command: commandName, - kind: .filesystem( - FilesystemPermissionRequest( - operation: .setPermissions, - path: normalized - ) - ) - ) - ) - try await base.setPermissions(path: normalized, permissions: permissions) - } - - func resolveRealPath(path: String) async throws -> String { - let normalized = try normalizedPath(path) - try await authorize( - .init( - command: commandName, - kind: .filesystem( - FilesystemPermissionRequest( - operation: .resolveRealPath, - path: normalized - ) - ) - ) - ) - return try await base.resolveRealPath(path: normalized) - } - - func exists(path: String) async -> Bool { - do { - let normalized = try normalizedPath(path) - try await authorize( - .init( - command: commandName, - kind: .filesystem( - FilesystemPermissionRequest( - operation: .exists, - path: normalized - ) - ) - ) - ) - return await base.exists(path: normalized) - } catch { - return false - } - } - - func glob(pattern: String, currentDirectory: String) async throws -> [String] { - try PathUtils.validate(pattern) - let normalizedCurrentDirectory = try normalizedPath(currentDirectory) - let normalizedPattern = PathUtils.normalize( - path: pattern, - currentDirectory: normalizedCurrentDirectory - ) - try await authorize( - .init( - command: commandName, - kind: .filesystem( - FilesystemPermissionRequest( - operation: .glob, - path: normalizedPattern, - destinationPath: normalizedCurrentDirectory - ) - ) - ) - ) - return try await base.glob( - pattern: normalizedPattern, - currentDirectory: normalizedCurrentDirectory - ) - } - - private func normalizedPath(_ path: String) throws -> String { - try PathUtils.validate(path) - return PathUtils.normalize(path: path, currentDirectory: "/") - } - - private func authorize(_ request: PermissionRequest) async throws { - let decision = await authorizePermissionRequest( - request, - using: permissionAuthorizer, - pausing: executionControl - ) - - if case let .deny(message) = decision { - throw ShellError.unsupported( - message ?? defaultDenialMessage(for: request) - ) - } - } - - private func defaultDenialMessage(for request: PermissionRequest) -> String { - guard case let .filesystem(filesystem) = request.kind else { - return "filesystem access denied" - } - - let target = filesystem.path - ?? filesystem.sourcePath - ?? filesystem.destinationPath - ?? "" - return "filesystem access denied: \(filesystem.operation.rawValue) \(target)" - } -} diff --git a/Sources/Bash/Support/Permissions.swift b/Sources/Bash/Support/Permissions.swift index 0fc7576..fd900fa 100644 --- a/Sources/Bash/Support/Permissions.swift +++ b/Sources/Bash/Support/Permissions.swift @@ -6,10 +6,10 @@ import Darwin import Glibc #endif -public struct PermissionRequest: Sendable, Hashable { +public struct ShellPermissionRequest: Sendable, Hashable { public enum Kind: Sendable, Hashable { - case network(NetworkPermissionRequest) - case filesystem(FilesystemPermissionRequest) + case network(ShellNetworkPermissionRequest) + case filesystem(ShellFilesystemPermissionRequest) } public var command: String @@ -21,7 +21,7 @@ public struct PermissionRequest: Sendable, Hashable { } } -public struct NetworkPermissionRequest: Sendable, Hashable { +public struct ShellNetworkPermissionRequest: Sendable, Hashable { public var url: String public var method: String @@ -31,7 +31,7 @@ public struct NetworkPermissionRequest: Sendable, Hashable { } } -public enum FilesystemPermissionOperation: String, Sendable, Hashable { +public enum ShellFilesystemPermissionOperation: String, Sendable, Hashable { case stat case listDirectory case readFile @@ -49,8 +49,8 @@ public enum FilesystemPermissionOperation: String, Sendable, Hashable { case glob } -public struct FilesystemPermissionRequest: Sendable, Hashable { - public var operation: FilesystemPermissionOperation +public struct ShellFilesystemPermissionRequest: Sendable, Hashable { + public var operation: ShellFilesystemPermissionOperation public var path: String? public var sourcePath: String? public var destinationPath: String? @@ -58,7 +58,7 @@ public struct FilesystemPermissionRequest: Sendable, Hashable { public var recursive: Bool public init( - operation: FilesystemPermissionOperation, + operation: ShellFilesystemPermissionOperation, path: String? = nil, sourcePath: String? = nil, destinationPath: String? = nil, @@ -74,9 +74,9 @@ public struct FilesystemPermissionRequest: Sendable, Hashable { } } -public struct NetworkPolicy: Sendable { - public static let disabled = NetworkPolicy() - public static let unrestricted = NetworkPolicy(allowsHTTPRequests: true) +public struct ShellNetworkPolicy: Sendable { + public static let disabled = ShellNetworkPolicy() + public static let unrestricted = ShellNetworkPolicy(allowsHTTPRequests: true) public var allowsHTTPRequests: Bool public var allowedHosts: [String] @@ -100,32 +100,32 @@ public struct NetworkPolicy: Sendable { } } -public enum PermissionDecision: Sendable { +public enum ShellPermissionDecision: Sendable { case allow case allowForSession case deny(message: String?) } -public protocol PermissionAuthorizing: Sendable { - func authorize(_ request: PermissionRequest) async -> PermissionDecision +public protocol ShellPermissionAuthorizing: Sendable { + func authorize(_ request: ShellPermissionRequest) async -> ShellPermissionDecision } -actor PermissionAuthorizer: PermissionAuthorizing { - typealias Handler = @Sendable (PermissionRequest) async -> PermissionDecision +actor ShellPermissionAuthorizer: ShellPermissionAuthorizing { + typealias Handler = @Sendable (ShellPermissionRequest) async -> ShellPermissionDecision - private let networkPolicy: NetworkPolicy + private let networkPolicy: ShellNetworkPolicy private let handler: Handler? - private var sessionAllows: Set = [] + private var sessionAllows: Set = [] init( - networkPolicy: NetworkPolicy = .disabled, + networkPolicy: ShellNetworkPolicy = .disabled, handler: Handler? = nil ) { self.networkPolicy = networkPolicy self.handler = handler } - func authorize(_ request: PermissionRequest) async -> PermissionDecision { + func authorize(_ request: ShellPermissionRequest) async -> ShellPermissionDecision { if let denial = PermissionPolicyEvaluator.denialMessage( for: request, networkPolicy: networkPolicy @@ -151,9 +151,9 @@ actor PermissionAuthorizer: PermissionAuthorizing { } func authorize( - _ request: PermissionRequest, + _ request: ShellPermissionRequest, pausing executionControl: ExecutionControl? - ) async -> PermissionDecision { + ) async -> ShellPermissionDecision { if let denial = PermissionPolicyEvaluator.denialMessage( for: request, networkPolicy: networkPolicy @@ -189,11 +189,11 @@ actor PermissionAuthorizer: PermissionAuthorizing { } func authorizePermissionRequest( - _ request: PermissionRequest, - using authorizer: any PermissionAuthorizing, + _ request: ShellPermissionRequest, + using authorizer: any ShellPermissionAuthorizing, pausing executionControl: ExecutionControl? -) async -> PermissionDecision { - if let authorizer = authorizer as? PermissionAuthorizer { +) async -> ShellPermissionDecision { + if let authorizer = authorizer as? ShellPermissionAuthorizer { return await authorizer.authorize(request, pausing: executionControl) } @@ -212,8 +212,8 @@ func authorizePermissionRequest( private enum PermissionPolicyEvaluator { static func denialMessage( - for request: PermissionRequest, - networkPolicy: NetworkPolicy + for request: ShellPermissionRequest, + networkPolicy: ShellNetworkPolicy ) -> String? { switch request.kind { case let .network(networkRequest): @@ -224,8 +224,8 @@ private enum PermissionPolicyEvaluator { } private static func denialMessage( - for request: NetworkPermissionRequest, - networkPolicy: NetworkPolicy + for request: ShellNetworkPermissionRequest, + networkPolicy: ShellNetworkPolicy ) -> String? { guard networkPolicy.allowsHTTPRequests else { return "network access denied by policy: outbound HTTP(S) access is disabled" diff --git a/Sources/Bash/Support/ShellPermissionedFileSystem.swift b/Sources/Bash/Support/ShellPermissionedFileSystem.swift new file mode 100644 index 0000000..1a150d2 --- /dev/null +++ b/Sources/Bash/Support/ShellPermissionedFileSystem.swift @@ -0,0 +1,298 @@ +import Foundation +import Workspace + +final class ShellPermissionedFileSystem: FileSystem, @unchecked Sendable { + let base: any FileSystem + private let commandName: String + private let permissionAuthorizer: any ShellPermissionAuthorizing + private let executionControl: ExecutionControl? + + init( + base: any FileSystem, + commandName: String, + permissionAuthorizer: any ShellPermissionAuthorizing, + executionControl: ExecutionControl? + ) { + self.base = Self.unwrap(base) + self.commandName = commandName + self.permissionAuthorizer = permissionAuthorizer + self.executionControl = executionControl + } + + static func unwrap(_ filesystem: any FileSystem) -> any FileSystem { + if let filesystem = filesystem as? ShellPermissionedFileSystem { + return filesystem.base + } + return filesystem + } + + func configure(rootDirectory: URL) async throws { + try await base.configure(rootDirectory: rootDirectory) + } + + func stat(path: WorkspacePath) async throws -> FileInfo { + try await authorize( + .init( + command: commandName, + kind: .filesystem( + ShellFilesystemPermissionRequest( + operation: .stat, + path: path.string + ) + ) + ) + ) + return try await base.stat(path: path) + } + + func listDirectory(path: WorkspacePath) async throws -> [DirectoryEntry] { + try await authorize( + .init( + command: commandName, + kind: .filesystem( + ShellFilesystemPermissionRequest( + operation: .listDirectory, + path: path.string + ) + ) + ) + ) + return try await base.listDirectory(path: path) + } + + func readFile(path: WorkspacePath) async throws -> Data { + try await authorize( + .init( + command: commandName, + kind: .filesystem( + ShellFilesystemPermissionRequest( + operation: .readFile, + path: path.string + ) + ) + ) + ) + return try await base.readFile(path: path) + } + + func writeFile(path: WorkspacePath, data: Data, append: Bool) async throws { + try await authorize( + .init( + command: commandName, + kind: .filesystem( + ShellFilesystemPermissionRequest( + operation: .writeFile, + path: path.string, + append: append + ) + ) + ) + ) + try await base.writeFile(path: path, data: data, append: append) + } + + func createDirectory(path: WorkspacePath, recursive: Bool) async throws { + try await authorize( + .init( + command: commandName, + kind: .filesystem( + ShellFilesystemPermissionRequest( + operation: .createDirectory, + path: path.string, + recursive: recursive + ) + ) + ) + ) + try await base.createDirectory(path: path, recursive: recursive) + } + + func remove(path: WorkspacePath, recursive: Bool) async throws { + try await authorize( + .init( + command: commandName, + kind: .filesystem( + ShellFilesystemPermissionRequest( + operation: .remove, + path: path.string, + recursive: recursive + ) + ) + ) + ) + try await base.remove(path: path, recursive: recursive) + } + + func move(from sourcePath: WorkspacePath, to destinationPath: WorkspacePath) async throws { + try await authorize( + .init( + command: commandName, + kind: .filesystem( + ShellFilesystemPermissionRequest( + operation: .move, + sourcePath: sourcePath.string, + destinationPath: destinationPath.string + ) + ) + ) + ) + try await base.move(from: sourcePath, to: destinationPath) + } + + func copy(from sourcePath: WorkspacePath, to destinationPath: WorkspacePath, recursive: Bool) async throws { + try await authorize( + .init( + command: commandName, + kind: .filesystem( + ShellFilesystemPermissionRequest( + operation: .copy, + sourcePath: sourcePath.string, + destinationPath: destinationPath.string, + recursive: recursive + ) + ) + ) + ) + try await base.copy(from: sourcePath, to: destinationPath, recursive: recursive) + } + + func createSymlink(path: WorkspacePath, target: String) async throws { + let normalizedTarget = try WorkspacePath(validating: target, relativeTo: path.dirname) + try await authorize( + .init( + command: commandName, + kind: .filesystem( + ShellFilesystemPermissionRequest( + operation: .createSymlink, + path: path.string, + destinationPath: normalizedTarget.string + ) + ) + ) + ) + try await base.createSymlink(path: path, target: target) + } + + func createHardLink(path: WorkspacePath, target: WorkspacePath) async throws { + try await authorize( + .init( + command: commandName, + kind: .filesystem( + ShellFilesystemPermissionRequest( + operation: .createHardLink, + path: path.string, + destinationPath: target.string + ) + ) + ) + ) + try await base.createHardLink(path: path, target: target) + } + + func readSymlink(path: WorkspacePath) async throws -> String { + try await authorize( + .init( + command: commandName, + kind: .filesystem( + ShellFilesystemPermissionRequest( + operation: .readSymlink, + path: path.string + ) + ) + ) + ) + return try await base.readSymlink(path: path) + } + + func setPermissions(path: WorkspacePath, permissions: POSIXPermissions) async throws { + try await authorize( + .init( + command: commandName, + kind: .filesystem( + ShellFilesystemPermissionRequest( + operation: .setPermissions, + path: path.string + ) + ) + ) + ) + try await base.setPermissions(path: path, permissions: permissions) + } + + func resolveRealPath(path: WorkspacePath) async throws -> WorkspacePath { + try await authorize( + .init( + command: commandName, + kind: .filesystem( + ShellFilesystemPermissionRequest( + operation: .resolveRealPath, + path: path.string + ) + ) + ) + ) + return try await base.resolveRealPath(path: path) + } + + func exists(path: WorkspacePath) async -> Bool { + do { + try await authorize( + .init( + command: commandName, + kind: .filesystem( + ShellFilesystemPermissionRequest( + operation: .exists, + path: path.string + ) + ) + ) + ) + return await base.exists(path: path) + } catch { + return false + } + } + + func glob(pattern: String, currentDirectory: WorkspacePath) async throws -> [WorkspacePath] { + let normalizedPattern = try WorkspacePath(validating: pattern, relativeTo: currentDirectory) + try await authorize( + .init( + command: commandName, + kind: .filesystem( + ShellFilesystemPermissionRequest( + operation: .glob, + path: normalizedPattern.string, + destinationPath: currentDirectory.string + ) + ) + ) + ) + return try await base.glob(pattern: normalizedPattern.string, currentDirectory: currentDirectory) + } + + private func authorize(_ request: ShellPermissionRequest) async throws { + let decision = await authorizePermissionRequest( + request, + using: permissionAuthorizer, + pausing: executionControl + ) + + if case let .deny(message) = decision { + throw ShellError.unsupported( + message ?? defaultDenialMessage(for: request) + ) + } + } + + private func defaultDenialMessage(for request: ShellPermissionRequest) -> String { + guard case let .filesystem(filesystem) = request.kind else { + return "filesystem access denied" + } + + let target = filesystem.path + ?? filesystem.sourcePath + ?? filesystem.destinationPath + ?? "" + return "filesystem access denied: \(filesystem.operation.rawValue) \(target)" + } +} diff --git a/Sources/Bash/Support/Types.swift b/Sources/Bash/Support/Types.swift index 4cd6610..d248f39 100644 --- a/Sources/Bash/Support/Types.swift +++ b/Sources/Bash/Support/Types.swift @@ -140,27 +140,27 @@ public struct DefaultSecretOutputRedactor: SecretOutputRedacting { } public struct SessionOptions: Sendable { - public var filesystem: any ShellFilesystem + public var filesystem: any FileSystem public var layout: SessionLayout public var initialEnvironment: [String: String] public var enableGlobbing: Bool public var maxHistory: Int - public var networkPolicy: NetworkPolicy + public var networkPolicy: ShellNetworkPolicy public var executionLimits: ExecutionLimits - public var permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? + public var permissionHandler: (@Sendable (ShellPermissionRequest) async -> ShellPermissionDecision)? public var secretPolicy: SecretHandlingPolicy public var secretResolver: (any SecretReferenceResolving)? public var secretOutputRedactor: any SecretOutputRedacting public init( - filesystem: any ShellFilesystem = ReadWriteFilesystem(), + filesystem: any FileSystem = ReadWriteFilesystem(), layout: SessionLayout = .unixLike, initialEnvironment: [String: String] = [:], enableGlobbing: Bool = true, maxHistory: Int = 1_000, - networkPolicy: NetworkPolicy = .disabled, + networkPolicy: ShellNetworkPolicy = .disabled, executionLimits: ExecutionLimits = .default, - permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? = nil, + permissionHandler: (@Sendable (ShellPermissionRequest) async -> ShellPermissionDecision)? = nil, secretPolicy: SecretHandlingPolicy = .off, secretResolver: (any SecretReferenceResolving)? = nil, secretOutputRedactor: any SecretOutputRedacting = DefaultSecretOutputRedactor() diff --git a/Sources/Bash/WorkspaceCompat.swift b/Sources/Bash/WorkspaceCompat.swift deleted file mode 100644 index e0f7483..0000000 --- a/Sources/Bash/WorkspaceCompat.swift +++ /dev/null @@ -1,283 +0,0 @@ -@_exported import Workspace -import Foundation - -public typealias WorkspaceFilesystem = ShellFilesystem - -public struct FileInfo: Sendable, Codable { - public var path: String - public var isDirectory: Bool - public var isSymbolicLink: Bool - public var size: UInt64 - public var permissions: Int - public var modificationDate: Date? - - public init( - path: String, - isDirectory: Bool, - isSymbolicLink: Bool, - size: UInt64, - permissions: Int, - modificationDate: Date? - ) { - self.path = path - self.isDirectory = isDirectory - self.isSymbolicLink = isSymbolicLink - self.size = size - self.permissions = permissions - self.modificationDate = modificationDate - } -} - -public struct DirectoryEntry: Sendable, Codable { - public var name: String - public var info: FileInfo - - public init(name: String, info: FileInfo) { - self.name = name - self.info = info - } -} - -public protocol ShellFilesystem: AnyObject, Sendable { - func configure(rootDirectory: URL) async throws - - func stat(path: String) async throws -> FileInfo - func listDirectory(path: String) async throws -> [DirectoryEntry] - func readFile(path: String) async throws -> Data - func writeFile(path: String, data: Data, append: Bool) async throws - func createDirectory(path: String, recursive: Bool) async throws - func remove(path: String, recursive: Bool) async throws - func move(from sourcePath: String, to destinationPath: String) async throws - func copy(from sourcePath: String, to destinationPath: String, recursive: Bool) async throws - func createSymlink(path: String, target: String) async throws - func createHardLink(path: String, target: String) async throws - func readSymlink(path: String) async throws -> String - func setPermissions(path: String, permissions: Int) async throws - func resolveRealPath(path: String) async throws -> String - - func exists(path: String) async -> Bool - func glob(pattern: String, currentDirectory: String) async throws -> [String] -} - -public extension ShellFilesystem where Self: FileSystem { - func stat(path: String) async throws -> FileInfo { - let info = try await (self as any FileSystem).stat(path: try workspacePath(path)) - return FileInfo( - path: info.path.string, - isDirectory: info.kind == .directory, - isSymbolicLink: info.kind == .symlink, - size: info.size, - permissions: Int(info.permissions.rawValue), - modificationDate: info.modificationDate - ) - } - - func listDirectory(path: String) async throws -> [DirectoryEntry] { - let entries = try await (self as any FileSystem).listDirectory(path: try workspacePath(path)) - return entries.map { entry in - DirectoryEntry( - name: entry.name, - info: FileInfo( - path: entry.info.path.string, - isDirectory: entry.info.kind == .directory, - isSymbolicLink: entry.info.kind == .symlink, - size: entry.info.size, - permissions: Int(entry.info.permissions.rawValue), - modificationDate: entry.info.modificationDate - ) - ) - } - } - - func readFile(path: String) async throws -> Data { - try await (self as any FileSystem).readFile(path: try workspacePath(path)) - } - - func writeFile(path: String, data: Data, append: Bool) async throws { - try await (self as any FileSystem).writeFile(path: try workspacePath(path), data: data, append: append) - } - - func createDirectory(path: String, recursive: Bool) async throws { - try await (self as any FileSystem).createDirectory(path: try workspacePath(path), recursive: recursive) - } - - func remove(path: String, recursive: Bool) async throws { - try await (self as any FileSystem).remove(path: try workspacePath(path), recursive: recursive) - } - - func move(from sourcePath: String, to destinationPath: String) async throws { - try await (self as any FileSystem).move( - from: try workspacePath(sourcePath), - to: try workspacePath(destinationPath) - ) - } - - func copy(from sourcePath: String, to destinationPath: String, recursive: Bool) async throws { - try await (self as any FileSystem).copy( - from: try workspacePath(sourcePath), - to: try workspacePath(destinationPath), - recursive: recursive - ) - } - - func createSymlink(path: String, target: String) async throws { - try await (self as any FileSystem).createSymlink(path: try workspacePath(path), target: target) - } - - func createHardLink(path: String, target: String) async throws { - try await (self as any FileSystem).createHardLink( - path: try workspacePath(path), - target: try workspacePath(target) - ) - } - - func readSymlink(path: String) async throws -> String { - try await (self as any FileSystem).readSymlink(path: try workspacePath(path)) - } - - func setPermissions(path: String, permissions: Int) async throws { - try await (self as any FileSystem).setPermissions( - path: try workspacePath(path), - permissions: POSIXPermissions(permissions) - ) - } - - func resolveRealPath(path: String) async throws -> String { - let realPath = try await (self as any FileSystem).resolveRealPath(path: try workspacePath(path)) - return realPath.string - } - - func exists(path: String) async -> Bool { - do { - return await (self as any FileSystem).exists(path: try workspacePath(path)) - } catch { - return false - } - } - - func glob(pattern: String, currentDirectory: String) async throws -> [String] { - let matches = try await (self as any FileSystem).glob( - pattern: pattern, - currentDirectory: try workspacePath(currentDirectory) - ) - return matches.map(\.string) - } -} - -extension ReadWriteFilesystem: ShellFilesystem {} -extension InMemoryFilesystem: ShellFilesystem {} -extension MountableFilesystem: ShellFilesystem {} -extension OverlayFilesystem: ShellFilesystem {} -extension SandboxFilesystem: ShellFilesystem {} -extension SecurityScopedFilesystem: ShellFilesystem {} -extension PermissionedFileSystem: ShellFilesystem {} - -enum PathUtils { - static func validate(_ path: String) throws { - if path.contains("\u{0}") { - throw ShellError.invalidPath(path) - } - } - - static func normalize(path: String, currentDirectory: String) -> String { - if path.isEmpty { - return currentDirectory - } - - let base: [String] - if path.hasPrefix("/") { - base = [] - } else { - base = splitComponents(currentDirectory) - } - - var parts = base - for piece in path.split(separator: "/", omittingEmptySubsequences: true) { - switch piece { - case ".": - continue - case "..": - if !parts.isEmpty { - parts.removeLast() - } - default: - parts.append(String(piece)) - } - } - - return "/" + parts.joined(separator: "/") - } - - static func splitComponents(_ absolutePath: String) -> [String] { - absolutePath.split(separator: "/", omittingEmptySubsequences: true).map(String.init) - } - - static func basename(_ path: String) -> String { - let normalized = path == "/" ? "/" : path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - if normalized == "/" || normalized.isEmpty { - return "/" - } - return normalized.split(separator: "/").last.map(String.init) ?? "/" - } - - static func dirname(_ path: String) -> String { - let normalized = normalize(path: path, currentDirectory: "/") - if normalized == "/" { - return "/" - } - - var parts = splitComponents(normalized) - _ = parts.popLast() - if parts.isEmpty { - return "/" - } - return "/" + parts.joined(separator: "/") - } - - static func join(_ lhs: String, _ rhs: String) -> String { - if rhs.hasPrefix("/") { - return normalize(path: rhs, currentDirectory: "/") - } - - let separator = lhs.hasSuffix("/") ? "" : "/" - return normalize(path: lhs + separator + rhs, currentDirectory: "/") - } - - static func containsGlob(_ token: String) -> Bool { - token.contains("*") || token.contains("?") || token.contains("[") - } - - static func globToRegex(_ pattern: String) -> String { - var regex = "^" - var index = pattern.startIndex - - while index < pattern.endIndex { - let char = pattern[index] - if char == "*" { - regex += ".*" - } else if char == "?" { - regex += "." - } else if char == "[" { - if let closeIndex = pattern[index...].firstIndex(of: "]") { - let range = pattern.index(after: index).. WorkspacePath { - try PathUtils.validate(path) - let normalized = PathUtils.normalize(path: path, currentDirectory: currentDirectory) - return try WorkspacePath(validating: normalized) -} diff --git a/Sources/Bash/WorkspaceSupport.swift b/Sources/Bash/WorkspaceSupport.swift new file mode 100644 index 0000000..bb8309d --- /dev/null +++ b/Sources/Bash/WorkspaceSupport.swift @@ -0,0 +1,45 @@ +@_exported import Workspace + +func shellPath( + _ path: String, + currentDirectory: String = "/" +) throws -> WorkspacePath { + try WorkspacePath( + validating: path, + relativeTo: WorkspacePath(normalizing: currentDirectory) + ) +} + +func validateWorkspacePath(_ path: String) throws { + _ = try WorkspacePath(validating: path) +} + +func normalizeWorkspacePath( + path: String, + currentDirectory: String +) -> String { + WorkspacePath( + normalizing: path, + relativeTo: WorkspacePath(normalizing: currentDirectory) + ).string +} + +public extension FileInfo { + var isDirectory: Bool { + kind == .directory + } + + var isSymbolicLink: Bool { + kind == .symlink + } + + var permissionBits: Int { + Int(permissions.rawValue) + } +} + +public extension POSIXPermissions { + var intValue: Int { + Int(rawValue) + } +} diff --git a/Sources/BashGit/GitEngine.swift b/Sources/BashGit/GitEngine.swift index 9259c5a..5079bba 100644 --- a/Sources/BashGit/GitEngine.swift +++ b/Sources/BashGit/GitEngine.swift @@ -43,11 +43,11 @@ private enum GitEngineError: Error { private struct CloneSource { let sourceURL: String let projection: GitRepositoryProjection? - let virtualPath: String? + let virtualPath: WorkspacePath? } private struct GitRepositoryProjection { - let virtualRoot: String + let virtualRoot: WorkspacePath let temporaryDirectory: URL let localRoot: URL @@ -55,7 +55,7 @@ private struct GitRepositoryProjection { try? FileManager.default.removeItem(at: temporaryDirectory) } - func syncBack(filesystem: any ShellFilesystem) async throws { + func syncBack(filesystem: any FileSystem) async throws { try await GitFilesystemProjection.syncFromLocal( localRoot: localRoot, toFilesystemRoot: virtualRoot, @@ -165,7 +165,7 @@ private enum GitEngineLibgit2 { } try await projection.syncBack(filesystem: context.filesystem) - let normalized = targetPath == "/" ? "/" : targetPath + "/" + let normalized = targetPath.isRoot ? "/" : targetPath.string + "/" return GitExecutionResult(stdout: "Initialized empty Git repository in \(normalized).git/\n", exitCode: 0) } @@ -440,7 +440,7 @@ private enum GitEngineLibgit2 { throw GitEngineError.usage("usage: git rev-parse --is-inside-work-tree\n") } - let start = normalizeAbsolute(context.currentDirectory) + let start = WorkspacePath(normalizing: context.currentDirectory) if let _ = try await GitFilesystemProjection.findRepositoryRoot( from: start, filesystem: context.filesystem @@ -484,7 +484,7 @@ private enum GitEngineLibgit2 { } private static func requireRepositoryProjection(context: CommandContext) async throws -> GitRepositoryProjection { - let start = normalizeAbsolute(context.currentDirectory) + let start = WorkspacePath(normalizing: context.currentDirectory) guard let repositoryRoot = try await GitFilesystemProjection.findRepositoryRoot( from: start, filesystem: context.filesystem @@ -559,7 +559,10 @@ private enum GitEngineLibgit2 { ) } - private static func defaultCloneDirectoryName(repositoryArgument: String, localRepositoryPath: String?) -> String { + private static func defaultCloneDirectoryName( + repositoryArgument: String, + localRepositoryPath: WorkspacePath? + ) -> String { if let localRepositoryPath { var name = basename(of: localRepositoryPath) if name.hasSuffix(".git") { @@ -891,47 +894,30 @@ private enum GitEngineLibgit2 { message.split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? "" } - private static func normalizeAbsolute(_ path: String) -> String { - let parts = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) - if parts.isEmpty { - return "/" - } - return "/" + parts.joined(separator: "/") + private static func normalizeAbsolute(_ path: String) -> WorkspacePath { + WorkspacePath(normalizing: path) } - private static func basename(of path: String) -> String { - let normalized = normalizeAbsolute(path) - if normalized == "/" { - return "/" - } - return normalized.split(separator: "/", omittingEmptySubsequences: true).last.map(String.init) ?? "" + private static func basename(of path: WorkspacePath) -> String { + path.basename } - private static func parent(of path: String) -> String { - let normalized = normalizeAbsolute(path) - if normalized == "/" { - return "/" - } - var parts = normalized.split(separator: "/", omittingEmptySubsequences: true).map(String.init) - _ = parts.popLast() - if parts.isEmpty { - return "/" - } - return "/" + parts.joined(separator: "/") + private static func parent(of path: WorkspacePath) -> WorkspacePath { + path.dirname } - private static func relativePath(of absolutePath: String, fromRoot root: String) -> String? { - let normalizedAbsolute = normalizeAbsolute(absolutePath) - let normalizedRoot = normalizeAbsolute(root) + private static func relativePath(of absolutePath: WorkspacePath, fromRoot root: WorkspacePath) -> String? { + let normalizedAbsolute = absolutePath + let normalizedRoot = root if normalizedAbsolute == normalizedRoot { return "." } - let prefix = normalizedRoot == "/" ? "/" : normalizedRoot + "/" - guard normalizedAbsolute.hasPrefix(prefix) else { + let prefix = normalizedRoot.isRoot ? "/" : normalizedRoot.string + "/" + guard normalizedAbsolute.string.hasPrefix(prefix) else { return nil } - return String(normalizedAbsolute.dropFirst(prefix.count)) + return String(normalizedAbsolute.string.dropFirst(prefix.count)) } } @@ -943,26 +929,26 @@ private enum GitFilesystemProjection { } static func findRepositoryRoot( - from startPath: String, - filesystem: any ShellFilesystem - ) async throws -> String? { - var current = normalizeAbsolute(startPath) + from startPath: WorkspacePath, + filesystem: any FileSystem + ) async throws -> WorkspacePath? { + var current = startPath while true { - let dotGit = join(current, ".git") + let dotGit = current.appending(".git") if await filesystem.exists(path: dotGit) { return current } - if current == "/" { + if current.isRoot { return nil } - current = parent(of: current) + current = current.dirname } } static func materialize( - virtualRoot: String, - filesystem: any ShellFilesystem + virtualRoot: WorkspacePath, + filesystem: any FileSystem ) async throws -> GitRepositoryProjection { let tempBase = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let tempDirectory = tempBase.appendingPathComponent("BashGit-\(UUID().uuidString)", isDirectory: true) @@ -986,8 +972,8 @@ private enum GitFilesystemProjection { static func syncFromLocal( localRoot: URL, - toFilesystemRoot virtualRoot: String, - filesystem: any ShellFilesystem + toFilesystemRoot virtualRoot: WorkspacePath, + filesystem: any FileSystem ) async throws { if await !filesystem.exists(path: virtualRoot) { try await filesystem.createDirectory(path: virtualRoot, recursive: true) @@ -1004,20 +990,20 @@ private enum GitFilesystemProjection { }.sorted { depth(of: $0) < depth(of: $1) } for relativePath in localDirectoryPaths { - let fullPath = join(virtualRoot, relativePath) + let fullPath = virtualRoot.appending(relativePath) if await !filesystem.exists(path: fullPath) { try await filesystem.createDirectory(path: fullPath, recursive: true) } } for (relativePath, entry) in localEntries { - let fullPath = join(virtualRoot, relativePath) + let fullPath = virtualRoot.appending(relativePath) switch entry { case let .directory(permissions): if await !filesystem.exists(path: fullPath) { try await filesystem.createDirectory(path: fullPath, recursive: true) } - try? await filesystem.setPermissions(path: fullPath, permissions: permissions) + try? await filesystem.setPermissions(path: fullPath, permissions: POSIXPermissions(permissions)) case let .file(url, permissions): if let existing = filesystemEntries[relativePath], case .directory = existing { @@ -1025,36 +1011,36 @@ private enum GitFilesystemProjection { } let data = try Data(contentsOf: url) try await filesystem.writeFile(path: fullPath, data: data, append: false) - try? await filesystem.setPermissions(path: fullPath, permissions: permissions) + try? await filesystem.setPermissions(path: fullPath, permissions: POSIXPermissions(permissions)) case let .symlink(target, permissions): if await filesystem.exists(path: fullPath) { try? await filesystem.remove(path: fullPath, recursive: true) } try await filesystem.createSymlink(path: fullPath, target: target) - try? await filesystem.setPermissions(path: fullPath, permissions: permissions) + try? await filesystem.setPermissions(path: fullPath, permissions: POSIXPermissions(permissions)) } } let stalePaths = filesystemEntries.keys.filter { localEntries[$0] == nil }.sorted { depth(of: $0) > depth(of: $1) } for relativePath in stalePaths { - let fullPath = join(virtualRoot, relativePath) + let fullPath = virtualRoot.appending(relativePath) try? await filesystem.remove(path: fullPath, recursive: true) } } private static func copyFilesystemTree( - filesystem: any ShellFilesystem, - virtualPath: String, + filesystem: any FileSystem, + virtualPath: WorkspacePath, localURL: URL ) async throws { let info = try await filesystem.stat(path: virtualPath) if info.isDirectory { try FileManager.default.createDirectory(at: localURL, withIntermediateDirectories: true) - try setPermissions(url: localURL, permissions: info.permissions) + try setPermissions(url: localURL, permissions: info.permissionBits) let entries = try await filesystem.listDirectory(path: virtualPath) for entry in entries { - let childVirtualPath = join(virtualPath, entry.name) + let childVirtualPath = virtualPath.appending(entry.name) let childLocalURL = localURL.appendingPathComponent(entry.name, isDirectory: entry.info.isDirectory) if entry.info.isDirectory { try await copyFilesystemTree( @@ -1065,12 +1051,12 @@ private enum GitFilesystemProjection { } else if entry.info.isSymbolicLink { let target = try await filesystem.readSymlink(path: childVirtualPath) try FileManager.default.createSymbolicLink(atPath: childLocalURL.path, withDestinationPath: target) - try setPermissions(url: childLocalURL, permissions: entry.info.permissions) + try setPermissions(url: childLocalURL, permissions: entry.info.permissionBits) } else { let data = try await filesystem.readFile(path: childVirtualPath) try FileManager.default.createDirectory(at: childLocalURL.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: childLocalURL, options: .atomic) - try setPermissions(url: childLocalURL, permissions: entry.info.permissions) + try setPermissions(url: childLocalURL, permissions: entry.info.permissionBits) } } return @@ -1080,14 +1066,14 @@ private enum GitFilesystemProjection { let target = try await filesystem.readSymlink(path: virtualPath) try FileManager.default.createDirectory(at: localURL.deletingLastPathComponent(), withIntermediateDirectories: true) try FileManager.default.createSymbolicLink(atPath: localURL.path, withDestinationPath: target) - try setPermissions(url: localURL, permissions: info.permissions) + try setPermissions(url: localURL, permissions: info.permissionBits) return } let data = try await filesystem.readFile(path: virtualPath) try FileManager.default.createDirectory(at: localURL.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: localURL, options: .atomic) - try setPermissions(url: localURL, permissions: info.permissions) + try setPermissions(url: localURL, permissions: info.permissionBits) } private static func scanLocalEntries(localRoot: URL) throws -> [String: LocalEntryType] { @@ -1129,8 +1115,8 @@ private enum GitFilesystemProjection { } private static func scanFilesystemEntries( - filesystem: any ShellFilesystem, - root: String + filesystem: any FileSystem, + root: WorkspacePath ) async throws -> [String: RemoteEntryType] { var entries: [String: RemoteEntryType] = [:] if await !filesystem.exists(path: root) { @@ -1146,15 +1132,15 @@ private enum GitFilesystemProjection { } private static func scanFilesystemEntries( - filesystem: any ShellFilesystem, - absolutePath: String, + filesystem: any FileSystem, + absolutePath: WorkspacePath, relativePath: String, output: inout [String: RemoteEntryType] ) async throws { let listing = try await filesystem.listDirectory(path: absolutePath) for entry in listing { let childRelative = relativePath.isEmpty ? entry.name : relativePath + "/" + entry.name - let childAbsolute = join(absolutePath, entry.name) + let childAbsolute = absolutePath.appending(entry.name) if entry.info.isDirectory { output[childRelative] = .directory try await scanFilesystemEntries( @@ -1190,38 +1176,6 @@ private enum GitFilesystemProjection { return String(absolutePath.dropFirst(rootPath.count + 1)) } - private static func normalizeAbsolute(_ path: String) -> String { - let parts = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) - if parts.isEmpty { - return "/" - } - return "/" + parts.joined(separator: "/") - } - - private static func join(_ lhs: String, _ rhs: String) -> String { - if rhs.hasPrefix("/") { - return normalizeAbsolute(rhs) - } - let normalizedLHS = normalizeAbsolute(lhs) - if normalizedLHS == "/" { - return "/" + rhs - } - return normalizedLHS + "/" + rhs - } - - private static func parent(of path: String) -> String { - let normalized = normalizeAbsolute(path) - if normalized == "/" { - return "/" - } - var parts = normalized.split(separator: "/", omittingEmptySubsequences: true).map(String.init) - _ = parts.popLast() - if parts.isEmpty { - return "/" - } - return "/" + parts.joined(separator: "/") - } - private static func depth(of path: String) -> Int { path.split(separator: "/", omittingEmptySubsequences: true).count } diff --git a/Sources/BashPython/CPythonRuntime.swift b/Sources/BashPython/CPythonRuntime.swift index 7835eeb..fc1f34f 100644 --- a/Sources/BashPython/CPythonRuntime.swift +++ b/Sources/BashPython/CPythonRuntime.swift @@ -120,7 +120,7 @@ public actor CPythonRuntime: PythonRuntime { public func execute( request: PythonExecutionRequest, - filesystem: any ShellFilesystem + filesystem: any FileSystem ) async -> PythonExecutionResult { do { let runtime = try ensureRuntime() @@ -237,10 +237,10 @@ public actor CPythonRuntime: PythonRuntime { private final class CPythonFilesystemBridge: @unchecked Sendable { private let lock = NSLock() - private var filesystem: (any ShellFilesystem)? + private var filesystem: (any FileSystem)? private var currentDirectory: String = "/" - func setContext(filesystem: any ShellFilesystem, currentDirectory: String) { + func setContext(filesystem: any FileSystem, currentDirectory: String) { lock.lock() defer { lock.unlock() } self.filesystem = filesystem @@ -309,7 +309,7 @@ private final class CPythonFilesystemBridge: @unchecked Sendable { "isFile": !info.isDirectory, "isDirectory": info.isDirectory, "isSymbolicLink": info.isSymbolicLink, - "mode": info.permissions, + "mode": info.permissionBits, "size": info.size, "mtime": mtime, ] @@ -398,7 +398,7 @@ private final class CPythonFilesystemBridge: @unchecked Sendable { guard let filesystem = self.snapshot().filesystem else { throw CPythonRuntimeError.unavailable("filesystem bridge is not active") } - try await filesystem.setPermissions(path: path, permissions: mode) + try await filesystem.setPermissions(path: path, permissions: POSIXPermissions(mode)) } return response(success: [:]) @@ -410,7 +410,7 @@ private final class CPythonFilesystemBridge: @unchecked Sendable { } return try await filesystem.resolveRealPath(path: path) } - return response(success: ["path": value]) + return response(success: ["path": value.string]) default: return response(error: "unsupported operation: \(op)") @@ -420,13 +420,13 @@ private final class CPythonFilesystemBridge: @unchecked Sendable { } } - private func snapshot() -> (filesystem: (any ShellFilesystem)?, currentDirectory: String) { + private func snapshot() -> (filesystem: (any FileSystem)?, currentDirectory: String) { lock.lock() defer { lock.unlock() } return (filesystem, currentDirectory) } - private func resolvedPath(from payload: [String: Any]) throws -> String { + private func resolvedPath(from payload: [String: Any]) throws -> WorkspacePath { guard let path = payload["path"] as? String else { throw CPythonRuntimeError.executionFailed("filesystem path is required") } @@ -435,37 +435,11 @@ private final class CPythonFilesystemBridge: @unchecked Sendable { return normalize(path: path, currentDirectory: snapshot.currentDirectory) } - private func normalize(path: String, currentDirectory: String) -> String { - if path.isEmpty { - return currentDirectory - } - - let base: [String] - if path.hasPrefix("/") { - base = [] - } else { - base = splitComponents(currentDirectory) - } - - var parts = base - for piece in path.split(separator: "/", omittingEmptySubsequences: true) { - switch piece { - case ".": - continue - case "..": - if !parts.isEmpty { - parts.removeLast() - } - default: - parts.append(String(piece)) - } - } - - return "/" + parts.joined(separator: "/") - } - - private func splitComponents(_ absolutePath: String) -> [String] { - absolutePath.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + private func normalize(path: String, currentDirectory: String) -> WorkspacePath { + WorkspacePath( + normalizing: path, + relativeTo: WorkspacePath(normalizing: currentDirectory) + ) } private func runBlocking(_ operation: @escaping @Sendable () async throws -> T) throws -> T { @@ -512,11 +486,11 @@ private final class CPythonFilesystemBridge: @unchecked Sendable { private final class CPythonNetworkBridge: @unchecked Sendable { private let lock = NSLock() private var commandName = "python3" - private var permissionAuthorizer: (any PermissionAuthorizing)? + private var permissionAuthorizer: (any ShellPermissionAuthorizing)? func setContext( commandName: String, - permissionAuthorizer: (any PermissionAuthorizing)? + permissionAuthorizer: (any ShellPermissionAuthorizing)? ) { lock.lock() defer { lock.unlock() } @@ -549,9 +523,9 @@ private final class CPythonNetworkBridge: @unchecked Sendable { return response(success: [:]) } - let request = PermissionRequest( + let request = ShellPermissionRequest( command: snapshot.commandName, - kind: .network(NetworkPermissionRequest(url: url, method: method)) + kind: .network(ShellNetworkPermissionRequest(url: url, method: method)) ) do { @@ -567,7 +541,7 @@ private final class CPythonNetworkBridge: @unchecked Sendable { } } - private func snapshot() -> (commandName: String, permissionAuthorizer: (any PermissionAuthorizing)?) { + private func snapshot() -> (commandName: String, permissionAuthorizer: (any ShellPermissionAuthorizing)?) { lock.lock() defer { lock.unlock() } return (commandName, permissionAuthorizer) diff --git a/Sources/BashPython/PythonRuntime.swift b/Sources/BashPython/PythonRuntime.swift index 63d3893..159232b 100644 --- a/Sources/BashPython/PythonRuntime.swift +++ b/Sources/BashPython/PythonRuntime.swift @@ -15,7 +15,7 @@ public struct PythonExecutionRequest: Sendable { public var currentDirectory: String public var environment: [String: String] public var stdin: String - public var permissionAuthorizer: (any PermissionAuthorizing)? + public var permissionAuthorizer: (any ShellPermissionAuthorizing)? public init( commandName: String, @@ -26,7 +26,7 @@ public struct PythonExecutionRequest: Sendable { currentDirectory: String, environment: [String: String], stdin: String, - permissionAuthorizer: (any PermissionAuthorizing)? = nil + permissionAuthorizer: (any ShellPermissionAuthorizing)? = nil ) { self.commandName = commandName self.mode = mode @@ -55,7 +55,7 @@ public struct PythonExecutionResult: Sendable { public protocol PythonRuntime: Sendable { func execute( request: PythonExecutionRequest, - filesystem: any ShellFilesystem + filesystem: any FileSystem ) async -> PythonExecutionResult func versionString() async -> String @@ -116,7 +116,7 @@ struct UnsupportedPythonRuntime: PythonRuntime { func execute( request: PythonExecutionRequest, - filesystem: any ShellFilesystem + filesystem: any FileSystem ) async -> PythonExecutionResult { _ = request _ = filesystem diff --git a/Tests/BashGitTests/GitCommandTests.swift b/Tests/BashGitTests/GitCommandTests.swift index 5b0dbea..65867d9 100644 --- a/Tests/BashGitTests/GitCommandTests.swift +++ b/Tests/BashGitTests/GitCommandTests.swift @@ -148,7 +148,7 @@ struct GitCommandTests { @Test("clone remote repository respects network policy") func cloneRemoteRepositoryRespectsNetworkPolicy() async throws { let (session, root) = try await GitTestSupport.makeReadWriteSession( - networkPolicy: NetworkPolicy( + networkPolicy: ShellNetworkPolicy( allowsHTTPRequests: true, denyPrivateRanges: true ) @@ -163,7 +163,7 @@ struct GitCommandTests { @Test("clone ssh-style repository respects host allowlist") func cloneSSHStyleRepositoryRespectsHostAllowlist() async throws { let (session, root) = try await GitTestSupport.makeReadWriteSession( - networkPolicy: NetworkPolicy( + networkPolicy: ShellNetworkPolicy( allowsHTTPRequests: true, allowedHosts: ["gitlab.com"] ) diff --git a/Tests/BashGitTests/TestSupport.swift b/Tests/BashGitTests/TestSupport.swift index f38544f..5d5a835 100644 --- a/Tests/BashGitTests/TestSupport.swift +++ b/Tests/BashGitTests/TestSupport.swift @@ -11,8 +11,8 @@ enum GitTestSupport { } static func makeReadWriteSession( - networkPolicy: NetworkPolicy = .unrestricted, - permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? = nil + networkPolicy: ShellNetworkPolicy = .unrestricted, + permissionHandler: (@Sendable (ShellPermissionRequest) async -> ShellPermissionDecision)? = nil ) async throws -> (session: BashSession, root: URL) { let root = try makeTempDirectory() let session = try await BashSession( @@ -29,8 +29,8 @@ enum GitTestSupport { } static func makeInMemorySession( - networkPolicy: NetworkPolicy = .unrestricted, - permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? = nil + networkPolicy: ShellNetworkPolicy = .unrestricted, + permissionHandler: (@Sendable (ShellPermissionRequest) async -> ShellPermissionDecision)? = nil ) async throws -> BashSession { let session = try await BashSession( options: SessionOptions( diff --git a/Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift b/Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift index a26d3fd..c3b882c 100644 --- a/Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift +++ b/Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift @@ -115,7 +115,7 @@ struct CPythonRuntimeIntegrationTests { @BashPythonTestActor func networkPolicyBlocksPrivateSocketTargets() async throws { let (session, root) = try await PythonTestSupport.makeSession( - networkPolicy: NetworkPolicy( + networkPolicy: ShellNetworkPolicy( allowsHTTPRequests: true, denyPrivateRanges: true ) @@ -131,7 +131,7 @@ struct CPythonRuntimeIntegrationTests { @BashPythonTestActor func pythonNetworkChecksReuseHostCallbackAfterPolicyPasses() async throws { let (session, root) = try await PythonTestSupport.makeSession( - networkPolicy: NetworkPolicy( + networkPolicy: ShellNetworkPolicy( allowsHTTPRequests: true, allowedHosts: ["1.1.1.1"] ), diff --git a/Tests/BashPythonTests/TestSupport.swift b/Tests/BashPythonTests/TestSupport.swift index 5ed3f28..ddca92d 100644 --- a/Tests/BashPythonTests/TestSupport.swift +++ b/Tests/BashPythonTests/TestSupport.swift @@ -16,8 +16,8 @@ enum PythonTestSupport { } static func makeSession( - networkPolicy: NetworkPolicy = .unrestricted, - permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? = nil + networkPolicy: ShellNetworkPolicy = .unrestricted, + permissionHandler: (@Sendable (ShellPermissionRequest) async -> ShellPermissionDecision)? = nil ) async throws -> (session: BashSession, root: URL) { let root = try makeTempDirectory() let session = try await BashSession( @@ -32,8 +32,8 @@ enum PythonTestSupport { } static func makeInMemorySession( - networkPolicy: NetworkPolicy = .unrestricted, - permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? = nil + networkPolicy: ShellNetworkPolicy = .unrestricted, + permissionHandler: (@Sendable (ShellPermissionRequest) async -> ShellPermissionDecision)? = nil ) async throws -> BashSession { let options = SessionOptions( filesystem: InMemoryFilesystem(), @@ -53,7 +53,7 @@ enum PythonTestSupport { struct EchoPythonRuntime: PythonRuntime { func execute( request: PythonExecutionRequest, - filesystem: any ShellFilesystem + filesystem: any FileSystem ) async -> PythonExecutionResult { _ = filesystem diff --git a/Tests/BashSecretsTests/TestSupport.swift b/Tests/BashSecretsTests/TestSupport.swift index fb29acb..27ea725 100644 --- a/Tests/BashSecretsTests/TestSupport.swift +++ b/Tests/BashSecretsTests/TestSupport.swift @@ -22,7 +22,7 @@ enum SecretsTestSupport { static func makeSecretAwareSession( policy: SecretHandlingPolicy, - networkPolicy: NetworkPolicy = .disabled + networkPolicy: ShellNetworkPolicy = .disabled ) async throws -> (session: BashSession, root: URL) { try await makeSession( options: SessionOptions( diff --git a/Tests/BashTests/FilesystemOptionsTests.swift b/Tests/BashTests/FilesystemOptionsTests.swift index 8baf0d4..9d6be71 100644 --- a/Tests/BashTests/FilesystemOptionsTests.swift +++ b/Tests/BashTests/FilesystemOptionsTests.swift @@ -24,29 +24,32 @@ struct FilesystemOptionsTests { #expect(ls.stdoutString.contains("rootless.txt")) } - @Test("bash reexports workspace filesystem shims") - func bashReexportsWorkspaceFilesystemShims() async throws { - let workspaceFilesystem: any WorkspaceFilesystem = InMemoryFilesystem() - let shellFilesystem: any ShellFilesystem = workspaceFilesystem + @Test("bash reexports native workspace filesystem types") + func bashReexportsNativeWorkspaceFilesystemTypes() async throws { + let workspaceFilesystem: any FileSystem = InMemoryFilesystem() + let shellFilesystem: any FileSystem = workspaceFilesystem let inMemoryFilesystem = InMemoryFilesystem() - try await inMemoryFilesystem.writeFile(path: "/note.txt", data: Data("shim".utf8), append: false) + try await inMemoryFilesystem.writeFile( + path: WorkspacePath(normalizing: "/note.txt"), + data: Data("native".utf8), + append: false + ) await inMemoryFilesystem.reset() let info = FileInfo( - path: "/note.txt", - isDirectory: false, - isSymbolicLink: false, + path: WorkspacePath(normalizing: "/note.txt"), + kind: .file, size: 4, - permissions: 0o644, + permissions: POSIXPermissions(0o644), modificationDate: nil ) let entry = DirectoryEntry(name: "note.txt", info: info) - let error = WorkspaceError.unsupported("shim check") + let error = WorkspaceError.unsupported("native check") - #expect(await shellFilesystem.exists(path: "/")) - #expect(!(await inMemoryFilesystem.exists(path: "/note.txt"))) - #expect(entry.info.path == "/note.txt") - #expect(error.description.contains("shim check")) + #expect(await shellFilesystem.exists(path: .root)) + #expect(!(await inMemoryFilesystem.exists(path: WorkspacePath(normalizing: "/note.txt")))) + #expect(entry.info.path == WorkspacePath(normalizing: "/note.txt")) + #expect(error.description.contains("native check")) } @Test("overlay filesystem snapshots disk and keeps writes in memory") diff --git a/Tests/BashTests/ParserAndFilesystemTests.swift b/Tests/BashTests/ParserAndFilesystemTests.swift index 3b9340f..8dc60b0 100644 --- a/Tests/BashTests/ParserAndFilesystemTests.swift +++ b/Tests/BashTests/ParserAndFilesystemTests.swift @@ -182,24 +182,11 @@ struct ParserAndFilesystemTests { #expect(read.stderrString.contains("invalid path")) } - @Test("filesystems reject paths with null bytes") - func filesystemsRejectPathsWithNullBytes() async throws { - let inMemory = InMemoryFilesystem() - - do { - _ = try await inMemory.readFile(path: "/bad\u{0}name") - Issue.record("expected in-memory null-byte rejection") - } catch { - #expect("\(error)".contains("null byte")) - } - - let root = try TestSupport.makeTempDirectory(prefix: "BashNullPath") - defer { TestSupport.removeDirectory(root) } - - let readWrite = try ReadWriteFilesystem(rootDirectory: root) + @Test("workspace paths reject null bytes") + func workspacePathsRejectNullBytes() { do { - try await readWrite.writeFile(path: "/bad\u{0}name", data: Data(), append: false) - Issue.record("expected read-write null-byte rejection") + _ = try WorkspacePath(validating: "/bad\u{0}name") + Issue.record("expected workspace path validation to reject null bytes") } catch { #expect("\(error)".contains("null byte")) } diff --git a/Tests/BashTests/SessionIntegrationTests.swift b/Tests/BashTests/SessionIntegrationTests.swift index 672a638..bcaa8fd 100644 --- a/Tests/BashTests/SessionIntegrationTests.swift +++ b/Tests/BashTests/SessionIntegrationTests.swift @@ -3,13 +3,13 @@ import Testing @testable import Bash actor PermissionProbe { - private var requests: [PermissionRequest] = [] + private var requests: [ShellPermissionRequest] = [] - func record(_ request: PermissionRequest) { + func record(_ request: ShellPermissionRequest) { requests.append(request) } - func snapshot() -> [PermissionRequest] { + func snapshot() -> [ShellPermissionRequest] { requests } } @@ -1821,7 +1821,7 @@ struct SessionIntegrationTests { @Test("curl network policy can deny private ranges") func curlNetworkPolicyCanDenyPrivateRanges() async throws { let (session, root) = try await TestSupport.makeSession( - networkPolicy: NetworkPolicy( + networkPolicy: ShellNetworkPolicy( allowsHTTPRequests: true, denyPrivateRanges: true ) @@ -1836,7 +1836,7 @@ struct SessionIntegrationTests { @Test("curl network policy can deny urls outside allowlist") func curlNetworkPolicyCanDenyURLsOutsideAllowlist() async throws { let (session, root) = try await TestSupport.makeSession( - networkPolicy: NetworkPolicy( + networkPolicy: ShellNetworkPolicy( allowsHTTPRequests: true, allowedURLPrefixes: ["https://api.example.com/"] ) @@ -1861,7 +1861,7 @@ struct SessionIntegrationTests { @Test("curl allowlist matches path boundaries instead of raw prefixes") func curlAllowlistMatchesPathBoundariesInsteadOfRawPrefixes() async throws { let (session, root) = try await TestSupport.makeSession( - networkPolicy: NetworkPolicy( + networkPolicy: ShellNetworkPolicy( allowsHTTPRequests: true, allowedURLPrefixes: ["https://api.example.com/v1"] ) diff --git a/Tests/BashTests/TestSupport.swift b/Tests/BashTests/TestSupport.swift index eca9296..53505be 100644 --- a/Tests/BashTests/TestSupport.swift +++ b/Tests/BashTests/TestSupport.swift @@ -10,11 +10,11 @@ enum TestSupport { } static func makeSession( - filesystem: (any ShellFilesystem)? = nil, + filesystem: (any FileSystem)? = nil, layout: SessionLayout = .unixLike, enableGlobbing: Bool = true, - networkPolicy: NetworkPolicy = .disabled, - permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? = nil + networkPolicy: ShellNetworkPolicy = .disabled, + permissionHandler: (@Sendable (ShellPermissionRequest) async -> ShellPermissionDecision)? = nil ) async throws -> (session: BashSession, root: URL) { let root = try makeTempDirectory() let options = SessionOptions( From c347a713b33fd59690312ffc2993d1d606aa8309 Mon Sep 17 00:00:00 2001 From: Zac White Date: Mon, 23 Mar 2026 12:07:49 -0700 Subject: [PATCH 11/14] Secret improvements --- README.md | 34 +- Sources/Bash/BashSession.swift | 36 ++- Sources/Bash/Commands/CommandSupport.swift | 55 ++++ Sources/Bash/Commands/NetworkCommands.swift | 98 +++--- Sources/Bash/Core/ShellExecutor.swift | 47 +-- .../AppleKeychainSecretsRuntime.swift | 171 +++++++++- .../BashSecretsReferenceResolver.swift | 8 +- Sources/BashSecrets/BashSession+Secrets.swift | 15 +- .../BashSecrets/InMemorySecretsProvider.swift | 64 ++++ Sources/BashSecrets/SecretsCommand.swift | 294 +++++------------- Sources/BashSecrets/SecretsReference.swift | 73 +++-- Sources/BashSecrets/SecretsRuntime.swift | 183 ++++------- .../SecretsCommandTests.swift | 213 ++++++++----- Tests/BashSecretsTests/TestSupport.swift | 75 ++--- 14 files changed, 736 insertions(+), 630 deletions(-) create mode 100644 Sources/BashSecrets/InMemorySecretsProvider.swift diff --git a/README.md b/README.md index 7d886f9..709c26b 100644 --- a/README.md +++ b/README.md @@ -154,42 +154,46 @@ Optional `secrets` registration: ```swift import BashSecrets -await session.registerSecrets() +let provider = AppleKeychainSecretsProvider() +await session.registerSecrets(provider: provider) let ref = await session.run("secrets put --service app --account api", stdin: Data("token".utf8)) let use = await session.run("secrets run --env API_TOKEN=\(ref.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)) -- printenv API_TOKEN") print(use.stdoutString) ``` -`BashSecrets` defaults to Apple Keychain generic-password storage through Security.framework and emits opaque `secretref:v1:...` references. +`BashSecrets` uses provider-owned opaque `secretref:...` references. `AppleKeychainSecretsProvider` stores secrets in Apple Keychain generic-password entries and keeps the reference-sealing key in a reserved internal Keychain item so refs remain durable for that provider/backend. -For harness/tooling flows where the model should only handle references, use the `Secrets` API directly: +For harness/tooling flows where the model should only handle references, use the provider API directly: ```swift -let ref = try await Secrets.putGenericPassword( +let provider = AppleKeychainSecretsProvider() +let ref = try await provider.putGenericPassword( service: "app", account: "api", value: Data("token".utf8) ) // Resolve inside trusted tool code, not in model-visible shell output. -let secretValue = try await Secrets.resolveReference(ref) +let secretValue = try await provider.resolveReference(ref) ``` -For secret-aware command execution/redaction inside `BashSession`, configure a resolver and policy: +For secret-aware command execution/redaction inside `BashSession`, wire the same provider into the session: ```swift -let options = SessionOptions( - filesystem: ReadWriteFilesystem(), - layout: .unixLike, - secretPolicy: .strict, - secretResolver: BashSecretsReferenceResolver() +let session = try await BashSession( + rootDirectory: root, + options: SessionOptions( + filesystem: ReadWriteFilesystem(), + layout: .unixLike + ) ) -let session = try await BashSession(rootDirectory: root, options: options) +let provider = AppleKeychainSecretsProvider() +await session.registerSecrets(provider: provider, policy: .strict) ``` Policies: - `.off`: no automatic secret-reference resolution/redaction in builtins -- `.resolveAndRedact`: resolve refs (where supported) and redact/replace secrets in output +- `.resolveAndRedact`: resolve refs only in explicit sinks and redact caller-visible `stdout`/`stderr` - `.strict`: like `.resolveAndRedact`, plus blocks high-risk flows like `secrets get --reveal` ## Workspace Modules @@ -589,7 +593,7 @@ All implemented commands support `--help`. | --- | --- | | `sqlite3` | **Opt-in via `BashSQLite`**: modes `-list`, `-csv`, `-json`, `-line`, `-column`, `-table`, `-markdown`; `-header`, `-noheader`, `-separator `, `-newline `, `-nullvalue `, `-readonly`, `-bail`, `-cmd `, `-version`, `--`; syntax `sqlite3 [options] [database] [sql]` | | `python3` / `python` | **Opt-in via `BashPython`**: embedded CPython runtime (`python3 [OPTIONS] [-c CODE | -m MODULE | FILE] [ARGS...]`); supports `-c`, `-m`, `-V/--version`, stdin execution, and script/module execution against strict shell-filesystem shims (process/FFI escape APIs blocked) | -| `secrets` / `secret` | **Opt-in via `BashSecrets`**: `put`, `ref`, `get`, `delete`, `run`; Keychain generic-password backend with reference-first flows (`secretref:v1:...`) and explicit `get --reveal` for plaintext output | +| `secrets` / `secret` | **Opt-in via `BashSecrets`**: `put`, `get`, `delete`, `run`; provider-backed reference-first flows (`secretref:...`) and explicit `get --reveal` for plaintext output | | `jq` | `-r`, `-c`, `-e`, `-s`, `-n`, `-j`, `-S`; query + optional files. Query subset supports paths, `|`, `select(...)`, comparisons, `and`/`or`/`not`, `//` | | `yq` | `-r`, `-c`, `-e`, `-s`, `-n`, `-j`, `-S`; query + optional files (YAML + JSON input), same query subset as `jq` | | `xan` | subcommands: `count`, `headers`, `select`, `filter` | @@ -652,7 +656,7 @@ All implemented commands support `--help`. | `wget` | URL argument; `--version`, `-q/--quiet`, `-O/--output-document `, `--user-agent ` | | `html-to-markdown` | `-b/--bullet `, `-c/--code `, `-r/--hr `, `--heading-style `; input from file or stdin; strips `script/style/footer` blocks; supports nested lists and Markdown table rendering | -When `SessionOptions.secretPolicy` is `.resolveAndRedact` or `.strict`, `curl` resolves `secretref:v1:...` tokens in headers/body arguments and output redaction replaces resolved values with their reference tokens. +When the active secret policy is `.resolveAndRedact` or `.strict`, `curl` resolves `secretref:...` tokens in headers/body arguments and caller-visible output redaction replaces resolved values with their reference tokens. Normal shell file redirections keep plaintext bytes. When `SessionOptions.networkPolicy` is set, `curl`/`wget`, `git clone` remotes, and `BashPython` socket connections enforce the same built-in default-off HTTP(S), allowlist, and private-range rules. When `SessionOptions.permissionHandler` is set, shell filesystem operations and redirections ask it before reading or mutating files, `curl` and `wget` ask it before outbound HTTP(S) requests, `git clone` asks it before remote clones, and `BashPython` asks it before socket connections. Permission callback wait time is excluded from both `timeout` and run-level wall-clock budgets. `data:` and jailed `file:` URLs do not trigger network checks. diff --git a/Sources/Bash/BashSession.swift b/Sources/Bash/BashSession.swift index d96fcbc..4a2ee65 100644 --- a/Sources/Bash/BashSession.swift +++ b/Sources/Bash/BashSession.swift @@ -7,6 +7,9 @@ public final actor BashSession { let jobManager: ShellJobManager private let permissionAuthorizer: ShellPermissionAuthorizer var executionControlStore: ExecutionControl? + private var secretPolicyStore: SecretHandlingPolicy + private var secretResolverStore: (any SecretReferenceResolving)? + private var secretOutputRedactorStore: any SecretOutputRedacting var currentDirectoryStore: String var environmentStore: [String: String] @@ -283,7 +286,7 @@ public final actor BashSession { await register(erased) } - func register(_ command: AnyBuiltinCommand) async { + public func register(_ command: AnyBuiltinCommand) async { commandRegistry[command.name] = command for alias in command.aliases { @@ -298,6 +301,28 @@ public final actor BashSession { } } + public func configureSecrets( + policy: SecretHandlingPolicy, + resolver: (any SecretReferenceResolving)?, + redactor: any SecretOutputRedacting = DefaultSecretOutputRedactor() + ) { + secretPolicyStore = policy + secretResolverStore = resolver + secretOutputRedactorStore = redactor + } + + public func setSecretHandlingPolicy(_ policy: SecretHandlingPolicy) { + secretPolicyStore = policy + } + + public func setSecretResolver(_ resolver: (any SecretReferenceResolving)?) { + secretResolverStore = resolver + } + + public func setSecretOutputRedactor(_ redactor: any SecretOutputRedacting) { + secretOutputRedactorStore = redactor + } + private func setupLayout() async throws { switch options.layout { case .rootOnly: @@ -339,6 +364,9 @@ public final actor BashSession { handler: options.permissionHandler ) executionControlStore = nil + secretPolicyStore = options.secretPolicy + secretResolverStore = options.secretResolver + secretOutputRedactorStore = options.secretOutputRedactor commandRegistry = [:] shellFunctionStore = [:] @@ -396,9 +424,9 @@ public final actor BashSession { shellFunctions: [String: String], jobControl: (any ShellJobControlling)? ) async -> ShellExecutionResult { - let secretPolicy = options.secretPolicy - let secretResolver = options.secretResolver - let secretOutputRedactor = options.secretOutputRedactor + let secretPolicy = secretPolicyStore + let secretResolver = secretResolverStore + let secretOutputRedactor = secretOutputRedactorStore let secretTracker = secretPolicy == .off ? nil : SecretExposureTracker() var execution = await ShellExecutor.execute( diff --git a/Sources/Bash/Commands/CommandSupport.swift b/Sources/Bash/Commands/CommandSupport.swift index c764244..c6dc23f 100644 --- a/Sources/Bash/Commands/CommandSupport.swift +++ b/Sources/Bash/Commands/CommandSupport.swift @@ -149,3 +149,58 @@ enum CommandHash { Insecure.MD5.hash(data: data).map { String(format: "%02x", $0) }.joined() } } + +enum SecretAwareSinkSupport { + static let secretReferencePrefix = "secretref:" + + static func resolveSecretReferences( + in value: String, + context: inout CommandContext + ) async throws -> String { + guard value.contains(secretReferencePrefix) else { + return value + } + + var output = "" + var index = value.startIndex + + while index < value.endIndex { + guard let prefixRange = value[index...].range(of: secretReferencePrefix) else { + output += String(value[index...]) + break + } + + output += String(value[index.. Bool { + character == "-" || character == "_" || character.isLetter || character.isNumber + } +} diff --git a/Sources/Bash/Commands/NetworkCommands.swift b/Sources/Bash/Commands/NetworkCommands.swift index 54ec8e3..56b4f6b 100644 --- a/Sources/Bash/Commands/NetworkCommands.swift +++ b/Sources/Bash/Commands/NetworkCommands.swift @@ -477,7 +477,10 @@ struct CurlCommand: BuiltinCommand { let resolvedToken: String do { - resolvedToken = try await resolveSecretReferences(in: token, context: &context) + resolvedToken = try await SecretAwareSinkSupport.resolveSecretReferences( + in: token, + context: &context + ) } catch { context.writeStderr("curl: \(error)\n") return .failure(1) @@ -492,7 +495,10 @@ struct CurlCommand: BuiltinCommand { } else { let resolvedToken: String do { - resolvedToken = try await resolveSecretReferences(in: token, context: &context) + resolvedToken = try await SecretAwareSinkSupport.resolveSecretReferences( + in: token, + context: &context + ) } catch { context.writeStderr("curl: \(error)\n") return .failure(1) @@ -522,7 +528,10 @@ struct CurlCommand: BuiltinCommand { let resolvedToken: String do { - resolvedToken = try await resolveSecretReferences(in: token, context: &context) + resolvedToken = try await SecretAwareSinkSupport.resolveSecretReferences( + in: token, + context: &context + ) } catch { context.writeStderr("curl: \(error)\n") return .failure(1) @@ -534,7 +543,10 @@ struct CurlCommand: BuiltinCommand { for token in encodedTokens { let resolvedToken: String do { - resolvedToken = try await resolveSecretReferences(in: token, context: &context) + resolvedToken = try await SecretAwareSinkSupport.resolveSecretReferences( + in: token, + context: &context + ) } catch { context.writeStderr("curl: \(error)\n") return .failure(1) @@ -835,7 +847,10 @@ struct CurlCommand: BuiltinCommand { let value: String do { - value = try await resolveSecretReferences(in: rawValue, context: &context) + value = try await SecretAwareSinkSupport.resolveSecretReferences( + in: rawValue, + context: &context + ) } catch { context.writeStderr("curl: \(error)\n") return .failure(1) @@ -845,7 +860,10 @@ struct CurlCommand: BuiltinCommand { if let userAgent = options.userAgent { do { - parsed["User-Agent"] = try await resolveSecretReferences(in: userAgent, context: &context) + parsed["User-Agent"] = try await SecretAwareSinkSupport.resolveSecretReferences( + in: userAgent, + context: &context + ) } catch { context.writeStderr("curl: \(error)\n") return .failure(1) @@ -853,7 +871,10 @@ struct CurlCommand: BuiltinCommand { } if let referer = options.referer { do { - parsed["Referer"] = try await resolveSecretReferences(in: referer, context: &context) + parsed["Referer"] = try await SecretAwareSinkSupport.resolveSecretReferences( + in: referer, + context: &context + ) } catch { context.writeStderr("curl: \(error)\n") return .failure(1) @@ -862,7 +883,10 @@ struct CurlCommand: BuiltinCommand { if let user = options.user { let resolvedUser: String do { - resolvedUser = try await resolveSecretReferences(in: user, context: &context) + resolvedUser = try await SecretAwareSinkSupport.resolveSecretReferences( + in: user, + context: &context + ) } catch { context.writeStderr("curl: \(error)\n") return .failure(1) @@ -879,59 +903,6 @@ struct CurlCommand: BuiltinCommand { return .success(parsed) } - private static let secretReferencePrefix = "secretref:v1:" - - private static func resolveSecretReferences( - in value: String, - context: inout CommandContext - ) async throws -> String { - guard value.contains(secretReferencePrefix) else { - return value - } - - var output = "" - var index = value.startIndex - - while index < value.endIndex { - guard let prefixRange = value[index...].range(of: secretReferencePrefix) else { - output += String(value[index...]) - break - } - - output += String(value[index.. Bool { - character == "-" || character == "_" || character.isLetter || character.isNumber - } - private static func headerValue(named target: String, in headers: [String: String]) -> String? { let loweredTarget = target.lowercased() return headers.first { key, _ in @@ -1020,7 +991,10 @@ struct CurlCommand: BuiltinCommand { let resolvedValue: String do { - resolvedValue = try await resolveSecretReferences(in: rawValue, context: &context) + resolvedValue = try await SecretAwareSinkSupport.resolveSecretReferences( + in: rawValue, + context: &context + ) } catch { context.writeStderr("curl: \(error)\n") return .failure(1) diff --git a/Sources/Bash/Core/ShellExecutor.swift b/Sources/Bash/Core/ShellExecutor.swift index 60cbebb..2f8d017 100644 --- a/Sources/Bash/Core/ShellExecutor.swift +++ b/Sources/Bash/Core/ShellExecutor.swift @@ -405,14 +405,9 @@ enum ShellExecutor { normalizing: target, relativeTo: WorkspacePath(normalizing: currentDirectory) ) - let redactedOutput = await redactForExternalOutput( - result.stdout, - secretTracker: secretTracker, - secretOutputRedactor: secretOutputRedactor - ) try await commandFilesystem.writeFile( path: path, - data: redactedOutput, + data: result.stdout, append: redirection.type == .stdoutAppend ) result.stdout.removeAll(keepingCapacity: true) @@ -435,14 +430,9 @@ enum ShellExecutor { normalizing: target, relativeTo: WorkspacePath(normalizing: currentDirectory) ) - let redactedStderr = await redactForExternalOutput( - result.stderr, - secretTracker: secretTracker, - secretOutputRedactor: secretOutputRedactor - ) try await commandFilesystem.writeFile( path: path, - data: redactedStderr, + data: result.stderr, append: redirection.type == .stderrAppend ) result.stderr.removeAll(keepingCapacity: true) @@ -468,19 +458,9 @@ enum ShellExecutor { normalizing: target, relativeTo: WorkspacePath(normalizing: currentDirectory) ) - let redactedStdout = await redactForExternalOutput( - result.stdout, - secretTracker: secretTracker, - secretOutputRedactor: secretOutputRedactor - ) - let redactedStderr = await redactForExternalOutput( - result.stderr, - secretTracker: secretTracker, - secretOutputRedactor: secretOutputRedactor - ) var combined = Data() - combined.append(redactedStdout) - combined.append(redactedStderr) + combined.append(result.stdout) + combined.append(result.stderr) try await commandFilesystem.writeFile( path: path, data: combined, @@ -1808,23 +1788,4 @@ enum ShellExecutor { return nil } - private static func redactForExternalOutput( - _ data: Data, - secretTracker: SecretExposureTracker?, - secretOutputRedactor: any SecretOutputRedacting - ) async -> Data { - guard let secretTracker else { - return data - } - - let replacements = await secretTracker.snapshot() - guard !replacements.isEmpty else { - return data - } - - return secretOutputRedactor.redact( - data: data, - replacements: replacements - ) - } } diff --git a/Sources/BashSecrets/AppleKeychainSecretsRuntime.swift b/Sources/BashSecrets/AppleKeychainSecretsRuntime.swift index 4b1b292..8b74d2d 100644 --- a/Sources/BashSecrets/AppleKeychainSecretsRuntime.swift +++ b/Sources/BashSecrets/AppleKeychainSecretsRuntime.swift @@ -1,8 +1,17 @@ -#if canImport(Security) +#if canImport(CryptoKit) && canImport(Security) +import CryptoKit import Foundation import Security -public struct AppleKeychainSecretsRuntime: SecretsRuntime { +public actor AppleKeychainSecretsProvider: SecretsProvider { + private enum Constants { + static let referenceKeyService = "dev.velos.BashSecrets.reference-key" + static let referenceKeyAccount = "v2" + static let referenceKeyLabel = "BashSecrets reference key" + } + + private var cachedReferenceKey: SymmetricKey? + public init() {} public func putGenericPassword( @@ -10,7 +19,7 @@ public struct AppleKeychainSecretsRuntime: SecretsRuntime { value: Data, label: String?, update: Bool - ) async throws { + ) async throws -> String { let query = baseQuery(locator: locator) var attributes: [String: Any] = [ kSecValueData as String: value, @@ -23,7 +32,7 @@ public struct AppleKeychainSecretsRuntime: SecretsRuntime { let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) switch status { case errSecSuccess: - return + return try issueReference(for: locator) case errSecItemNotFound: break default: @@ -43,7 +52,7 @@ public struct AppleKeychainSecretsRuntime: SecretsRuntime { let addStatus = SecItemAdd(addQuery as CFDictionary, nil) switch addStatus { case errSecSuccess: - return + return try issueReference(for: locator) case errSecDuplicateItem: throw SecretsError.duplicateItem(locator) default: @@ -55,10 +64,16 @@ public struct AppleKeychainSecretsRuntime: SecretsRuntime { } } + public func reference(for locator: SecretLocator) async throws -> String { + _ = try loadMetadata(locator: locator) + return try issueReference(for: locator) + } + public func getGenericPassword( - locator: SecretLocator, + reference: String, revealValue: Bool ) async throws -> SecretFetchResult { + let locator = try locator(forReference: reference) let metadata = try loadMetadata(locator: locator) let value: Data? if revealValue { @@ -69,7 +84,8 @@ public struct AppleKeychainSecretsRuntime: SecretsRuntime { return SecretFetchResult(metadata: metadata, value: value) } - public func deleteGenericPassword(locator: SecretLocator) async throws -> Bool { + public func deleteReference(_ reference: String) async throws -> Bool { + let locator = try locator(forReference: reference) let query = baseQuery(locator: locator) let status = SecItemDelete(query as CFDictionary) @@ -87,6 +103,98 @@ public struct AppleKeychainSecretsRuntime: SecretsRuntime { } } + private func locator(forReference reference: String) throws -> SecretLocator { + try SecretReference.parseGenericPasswordReference( + reference, + using: try referenceKey() + ) + } + + private func issueReference(for locator: SecretLocator) throws -> String { + try SecretReference.makeGenericPasswordReference( + locator: locator, + using: try referenceKey() + ) + } + + private func referenceKey() throws -> SymmetricKey { + if let cachedReferenceKey { + return cachedReferenceKey + } + + if let existing = try loadReferenceKeyData() { + guard existing.count == 32 else { + throw SecretsError.runtimeFailure("keychain reference key payload is invalid") + } + let key = SymmetricKey(data: existing) + cachedReferenceKey = key + return key + } + + let key = SymmetricKey(size: .bits256) + let keyData = key.rawData + if try storeReferenceKeyData(keyData) { + cachedReferenceKey = key + return key + } + + if let existing = try loadReferenceKeyData(), existing.count == 32 { + let sharedKey = SymmetricKey(data: existing) + cachedReferenceKey = sharedKey + return sharedKey + } + + throw SecretsError.runtimeFailure("keychain reference key payload is invalid") + } + + private func loadReferenceKeyData() throws -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Constants.referenceKeyService, + kSecAttrAccount as String: Constants.referenceKeyAccount, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: kCFBooleanTrue as Any, + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + switch status { + case errSecSuccess: + guard let value = item as? Data else { + throw SecretsError.runtimeFailure("keychain returned a non-data reference key") + } + return value + case errSecItemNotFound: + return nil + default: + throw SecretsError.runtimeFailure( + "keychain read reference key failed: \(statusMessage(status)) (\(status))" + ) + } + } + + private func storeReferenceKeyData(_ data: Data) throws -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Constants.referenceKeyService, + kSecAttrAccount as String: Constants.referenceKeyAccount, + kSecAttrLabel as String: Constants.referenceKeyLabel, + kSecValueData as String: data, + ] + + let status = SecItemAdd(query as CFDictionary, nil) + switch status { + case errSecSuccess: + return true + case errSecDuplicateItem: + return false + default: + throw SecretsError.runtimeFailure( + "keychain write reference key failed: \(statusMessage(status)) (\(status))" + ) + } + } + private func loadMetadata(locator: SecretLocator) throws -> SecretMetadata { var query = baseQuery(locator: locator) query[kSecMatchLimit as String] = kSecMatchLimitOne @@ -152,8 +260,6 @@ public struct AppleKeychainSecretsRuntime: SecretsRuntime { kSecAttrAccount as String: locator.account, ] - // Keep optional keychain routing metadata in the item/query identity - // so same service/account can be scoped independently. if let keychain = locator.keychain, !keychain.isEmpty { query[kSecAttrGeneric as String] = Data(keychain.utf8) } @@ -172,9 +278,52 @@ public struct AppleKeychainSecretsRuntime: SecretsRuntime { case errSecDuplicateItem: return .duplicateItem(locator) default: - let message = SecCopyErrorMessageString(status, nil) as String? ?? "OSStatus \(status)" - return .runtimeFailure("keychain \(operation) failed: \(message) (\(status))") + return .runtimeFailure( + "keychain \(operation) failed: \(statusMessage(status)) (\(status))" + ) } } + + private func statusMessage(_ status: OSStatus) -> String { + SecCopyErrorMessageString(status, nil) as String? ?? "OSStatus \(status)" + } +} +#else +import Foundation + +public struct AppleKeychainSecretsProvider: SecretsProvider { + public init() {} + + public func putGenericPassword( + locator: SecretLocator, + value: Data, + label: String?, + update: Bool + ) async throws -> String { + _ = locator + _ = value + _ = label + _ = update + throw SecretsError.unsupported("keychain secrets are not supported on this platform") + } + + public func reference(for locator: SecretLocator) async throws -> String { + _ = locator + throw SecretsError.unsupported("keychain secrets are not supported on this platform") + } + + public func getGenericPassword( + reference: String, + revealValue: Bool + ) async throws -> SecretFetchResult { + _ = reference + _ = revealValue + throw SecretsError.unsupported("keychain secrets are not supported on this platform") + } + + public func deleteReference(_ reference: String) async throws -> Bool { + _ = reference + throw SecretsError.unsupported("keychain secrets are not supported on this platform") + } } #endif diff --git a/Sources/BashSecrets/BashSecretsReferenceResolver.swift b/Sources/BashSecrets/BashSecretsReferenceResolver.swift index 8e9880c..1d35913 100644 --- a/Sources/BashSecrets/BashSecretsReferenceResolver.swift +++ b/Sources/BashSecrets/BashSecretsReferenceResolver.swift @@ -2,9 +2,13 @@ import Foundation import Bash public struct BashSecretsReferenceResolver: SecretReferenceResolving { - public init() {} + public let provider: any SecretsProvider + + public init(provider: any SecretsProvider) { + self.provider = provider + } public func resolveSecretReference(_ reference: String) async throws -> Data { - try await Secrets.resolveReference(reference) + try await provider.resolveReference(reference) } } diff --git a/Sources/BashSecrets/BashSession+Secrets.swift b/Sources/BashSecrets/BashSession+Secrets.swift index 67a48cf..b0bcd7d 100644 --- a/Sources/BashSecrets/BashSession+Secrets.swift +++ b/Sources/BashSecrets/BashSession+Secrets.swift @@ -1,7 +1,18 @@ import Bash public extension BashSession { - func registerSecrets() async { - await register(SecretsCommand.self) + func registerSecrets( + provider: any SecretsProvider, + policy: SecretHandlingPolicy? = nil, + redactor: (any SecretOutputRedacting)? = nil + ) async { + await register(SecretsCommand.command(provider: provider)) + setSecretResolver(BashSecretsReferenceResolver(provider: provider)) + if let policy { + setSecretHandlingPolicy(policy) + } + if let redactor { + setSecretOutputRedactor(redactor) + } } } diff --git a/Sources/BashSecrets/InMemorySecretsProvider.swift b/Sources/BashSecrets/InMemorySecretsProvider.swift new file mode 100644 index 0000000..da6615d --- /dev/null +++ b/Sources/BashSecrets/InMemorySecretsProvider.swift @@ -0,0 +1,64 @@ +import CryptoKit +import Foundation + +public actor InMemorySecretsProvider: SecretsProvider { + private struct StoredValue { + var metadata: SecretMetadata + var value: Data + } + + private let referenceKey: SymmetricKey + private var values: [SecretLocator: StoredValue] = [:] + + public init(referenceKey: SymmetricKey = SymmetricKey(size: .bits256)) { + self.referenceKey = referenceKey + } + + public func putGenericPassword( + locator: SecretLocator, + value: Data, + label: String?, + update: Bool + ) async throws -> String { + if values[locator] != nil, !update { + throw SecretsError.duplicateItem(locator) + } + + values[locator] = StoredValue( + metadata: SecretMetadata(locator: locator, label: label), + value: value + ) + return try issueReference(for: locator) + } + + public func reference(for locator: SecretLocator) async throws -> String { + guard values[locator] != nil else { + throw SecretsError.notFound(locator) + } + return try issueReference(for: locator) + } + + public func getGenericPassword( + reference: String, + revealValue: Bool + ) async throws -> SecretFetchResult { + let locator = try SecretReference.parseGenericPasswordReference(reference, using: referenceKey) + guard let stored = values[locator] else { + throw SecretsError.notFound(locator) + } + + return SecretFetchResult( + metadata: stored.metadata, + value: revealValue ? stored.value : nil + ) + } + + public func deleteReference(_ reference: String) async throws -> Bool { + let locator = try SecretReference.parseGenericPasswordReference(reference, using: referenceKey) + return values.removeValue(forKey: locator) != nil + } + + private func issueReference(for locator: SecretLocator) throws -> String { + try SecretReference.makeGenericPasswordReference(locator: locator, using: referenceKey) + } +} diff --git a/Sources/BashSecrets/SecretsCommand.swift b/Sources/BashSecrets/SecretsCommand.swift index bb29cf5..33d2a6d 100644 --- a/Sources/BashSecrets/SecretsCommand.swift +++ b/Sources/BashSecrets/SecretsCommand.swift @@ -2,14 +2,7 @@ import ArgumentParser import Foundation import Bash -public struct SecretsCommand: BuiltinCommand { - public struct Options: ParsableArguments { - @Argument(parsing: .captureForPassthrough, help: "Subcommand") - public var arguments: [String] = [] - - public init() {} - } - +public struct SecretsCommand { public static let name = "secrets" public static let aliases = ["secret"] public static let overview = "Manage keychain-backed secrets using opaque references" @@ -21,39 +14,50 @@ public struct SecretsCommand: BuiltinCommand { SUBCOMMANDS: put Store or update a secret and emit a secret reference - ref Emit a secret reference for an existing secret get Read secret metadata, or reveal value with --reveal - delete Delete a secret by locator or reference + delete Delete a secret by reference run Resolve references into env vars for one command NOTES: - - References look like secretref:v1:... + - References look like secretref:... - Prefer 'put --stdin' to avoid putting secrets in command history. - 'get' does not reveal secret values unless --reveal is passed. """ - public static func run(context: inout CommandContext, options: Options) async -> Int32 { - guard let subcommand = options.arguments.first else { + public static func command(provider: any SecretsProvider) -> AnyBuiltinCommand { + AnyBuiltinCommand( + name: name, + aliases: aliases, + overview: overview + ) { context, args in + await run(context: &context, arguments: args, provider: provider) + } + } + + private static func run( + context: inout CommandContext, + arguments: [String], + provider: any SecretsProvider + ) async -> Int32 { + guard let subcommand = arguments.first else { context.writeStdout(helpText) return 0 } - let args = Array(options.arguments.dropFirst()) + let args = Array(arguments.dropFirst()) switch subcommand { case "help", "--help", "-h": context.writeStdout(helpText) return 0 case "put": - return await runPut(context: &context, arguments: args) - case "ref": - return await runRef(context: &context, arguments: args) + return await runPut(context: &context, arguments: args, provider: provider) case "get": - return await runGet(context: &context, arguments: args) + return await runGet(context: &context, arguments: args, provider: provider) case "delete", "rm": - return await runDelete(context: &context, arguments: args) + return await runDelete(context: &context, arguments: args, provider: provider) case "run": - return await runWithSecrets(context: &context, arguments: args) + return await runWithSecrets(context: &context, arguments: args, provider: provider) default: context.writeStderr("secrets: unknown subcommand '\(subcommand)'\n") context.writeStderr("secrets: run 'secrets --help' for usage\n") @@ -89,33 +93,10 @@ private extension SecretsCommand { var json = false } - struct RefOptions: ParsableArguments { - @Option(name: [.short, .long], help: "Service name") - var service: String - - @Option(name: [.customShort("a"), .long], help: "Account name") - var account: String - - @Option(name: [.customLong("keychain")], help: "Optional keychain routing metadata") - var keychain: String? - - @Flag(name: [.customLong("json")], help: "Emit JSON output") - var json = false - } - struct GetOptions: ParsableArguments { - @Argument(help: "Optional secret reference (secretref:v1:...)") + @Argument(help: "Secret reference (secretref:...)") var reference: String? - @Option(name: [.short, .long], help: "Service name") - var service: String? - - @Option(name: [.customShort("a"), .long], help: "Account name") - var account: String? - - @Option(name: [.customLong("keychain")], help: "Optional keychain routing metadata") - var keychain: String? - @Flag(name: [.customShort("w"), .customLong("reveal")], help: "Reveal and print secret value") var reveal = false @@ -124,18 +105,9 @@ private extension SecretsCommand { } struct DeleteOptions: ParsableArguments { - @Argument(help: "Optional secret reference (secretref:v1:...)") + @Argument(help: "Secret reference (secretref:...)") var reference: String? - @Option(name: [.short, .long], help: "Service name") - var service: String? - - @Option(name: [.customShort("a"), .long], help: "Account name") - var account: String? - - @Option(name: [.customLong("keychain")], help: "Optional keychain routing metadata") - var keychain: String? - @Flag(name: [.short, .long], help: "Succeed when secret is missing") var force = false } @@ -150,14 +122,18 @@ private extension SecretsCommand { var command: [String] } - static func runPut(context: inout CommandContext, arguments: [String]) async -> Int32 { + static func runPut( + context: inout CommandContext, + arguments: [String], + provider: any SecretsProvider + ) async -> Int32 { if arguments == ["--help"] || arguments == ["-h"] { context.writeStdout( """ OVERVIEW: Store or update a secret and emit a secret reference - + USAGE: secrets put --service --account [--stdin | --value ] [--update] [--json] - + """ ) return 0 @@ -180,9 +156,9 @@ private extension SecretsCommand { return emitError(context: &context, error: error) } - let runtime = await SecretsRuntimeRegistry.shared.currentRuntime() + let reference: String do { - try await runtime.putGenericPassword( + reference = try await provider.putGenericPassword( locator: locator, value: value, label: options.label, @@ -192,7 +168,6 @@ private extension SecretsCommand { return emitError(context: &context, error: error) } - let reference = SecretReference(locator: locator).stringValue if options.json { let payload = PutPayload( reference: reference, @@ -208,59 +183,18 @@ private extension SecretsCommand { return 0 } - static func runRef(context: inout CommandContext, arguments: [String]) async -> Int32 { + static func runGet( + context: inout CommandContext, + arguments: [String], + provider: any SecretsProvider + ) async -> Int32 { if arguments == ["--help"] || arguments == ["-h"] { context.writeStdout( """ - OVERVIEW: Emit a secret reference for an existing secret - - USAGE: secrets ref --service --account [--json] - - """ - ) - return 0 - } - - guard let options: RefOptions = parse(RefOptions.self, arguments: arguments, context: &context) else { - return 2 - } - - let locator = SecretLocator( - service: options.service, - account: options.account, - keychain: options.keychain - ) - - let runtime = await SecretsRuntimeRegistry.shared.currentRuntime() - do { - _ = try await runtime.getGenericPassword(locator: locator, revealValue: false) - } catch { - return emitError(context: &context, error: error) - } - - let reference = SecretReference(locator: locator).stringValue - if options.json { - let payload = ReferencePayload( - reference: reference, - service: locator.service, - account: locator.account, - keychain: locator.keychain - ) - return writeJSON(payload, context: &context) - } + OVERVIEW: Read secret metadata, or reveal value with --reveal - context.writeStdout(reference + "\n") - return 0 - } + USAGE: secrets get [--reveal] [--json] - static func runGet(context: inout CommandContext, arguments: [String]) async -> Int32 { - if arguments == ["--help"] || arguments == ["-h"] { - context.writeStdout( - """ - OVERVIEW: Read secret metadata, or reveal value with --reveal - - USAGE: secrets get [] [--service --account ] [--reveal] [--json] - """ ) return 0 @@ -282,24 +216,17 @@ private extension SecretsCommand { error: SecretsError.invalidInput("get --reveal is blocked by strict secret policy") ) } - - let locator: SecretLocator - do { - locator = try resolveLocator( - reference: options.reference, - service: options.service, - account: options.account, - keychain: options.keychain + guard let reference = options.reference, !reference.isEmpty else { + return emitError( + context: &context, + error: SecretsError.invalidInput("missing ") ) - } catch { - return emitError(context: &context, error: error) } - let runtime = await SecretsRuntimeRegistry.shared.currentRuntime() let fetched: SecretFetchResult do { - fetched = try await runtime.getGenericPassword( - locator: locator, + fetched = try await provider.getGenericPassword( + reference: reference, revealValue: options.reveal ) } catch { @@ -311,13 +238,12 @@ private extension SecretsCommand { return emitError( context: &context, error: SecretsError.runtimeFailure( - "secret value missing for service '\(locator.service)' and account '\(locator.account)'" + "secret value missing for service '\(fetched.metadata.locator.service)' and account '\(fetched.metadata.locator.account)'" ) ) } if context.secretPolicy != .off { - let reference = SecretReference(locator: fetched.metadata.locator).stringValue await context.registerSensitiveValue( value, replacement: Data(reference.utf8) @@ -328,7 +254,6 @@ private extension SecretsCommand { return 0 } - let reference = SecretReference(locator: fetched.metadata.locator).stringValue if options.json { let payload = GetPayload( reference: reference, @@ -349,14 +274,18 @@ private extension SecretsCommand { return 0 } - static func runDelete(context: inout CommandContext, arguments: [String]) async -> Int32 { + static func runDelete( + context: inout CommandContext, + arguments: [String], + provider: any SecretsProvider + ) async -> Int32 { if arguments == ["--help"] || arguments == ["-h"] { context.writeStdout( """ - OVERVIEW: Delete a secret by locator or reference - - USAGE: secrets delete [] [--service --account ] [--force] - + OVERVIEW: Delete a secret by reference + + USAGE: secrets delete [--force] + """ ) return 0 @@ -365,24 +294,20 @@ private extension SecretsCommand { guard let options: DeleteOptions = parse(DeleteOptions.self, arguments: arguments, context: &context) else { return 2 } - - let locator: SecretLocator - do { - locator = try resolveLocator( - reference: options.reference, - service: options.service, - account: options.account, - keychain: options.keychain + guard let reference = options.reference, !reference.isEmpty else { + return emitError( + context: &context, + error: SecretsError.invalidInput("missing ") ) - } catch { - return emitError(context: &context, error: error) } - let runtime = await SecretsRuntimeRegistry.shared.currentRuntime() do { - let removed = try await runtime.deleteGenericPassword(locator: locator) + let removed = try await provider.deleteReference(reference) if !removed, !options.force { - return emitError(context: &context, error: SecretsError.notFound(locator)) + return emitError( + context: &context, + error: SecretsError.runtimeFailure("secret not found for reference '\(reference)'") + ) } return 0 } catch { @@ -390,14 +315,18 @@ private extension SecretsCommand { } } - static func runWithSecrets(context: inout CommandContext, arguments: [String]) async -> Int32 { + static func runWithSecrets( + context: inout CommandContext, + arguments: [String], + provider: any SecretsProvider + ) async -> Int32 { if arguments == ["--help"] || arguments == ["-h"] { context.writeStdout( """ OVERVIEW: Resolve references into env vars for one command - + USAGE: secrets run --env NAME= [--env NAME= ...] -- [args...] - + """ ) return 0 @@ -413,42 +342,14 @@ private extension SecretsCommand { var ephemeralEnvironment = context.environment for binding in invocation.bindings { let data: Data - if context.secretPolicy == .off { - let locator: SecretLocator - guard let reference = SecretReference(string: binding.reference) else { - return emitError( - context: &context, - error: SecretsError.invalidReference(binding.reference) - ) - } - locator = reference.locator - - let fetched: SecretFetchResult - do { - let runtime = await SecretsRuntimeRegistry.shared.currentRuntime() - fetched = try await runtime.getGenericPassword( - locator: locator, - revealValue: true - ) - } catch { - return emitError(context: &context, error: error) - } - - guard let value = fetched.value else { - return emitError( - context: &context, - error: SecretsError.runtimeFailure( - "secret value missing for service '\(locator.service)' and account '\(locator.account)'" - ) - ) - } - data = value - } else { - do { + do { + if context.secretPolicy == .off { + data = try await provider.resolveReference(binding.reference) + } else { data = try await context.resolveSecretReference(binding.reference) - } catch { - return emitError(context: &context, error: error) } + } catch { + return emitError(context: &context, error: error) } guard let value = String(data: data, encoding: .utf8) else { @@ -514,34 +415,6 @@ private extension SecretsCommand { throw SecretsError.invalidInput("missing secret value (pass --stdin or --value)") } - static func resolveLocator( - reference: String?, - service: String?, - account: String?, - keychain: String? - ) throws -> SecretLocator { - if let reference { - if service != nil || account != nil || keychain != nil { - throw SecretsError.invalidInput( - "reference and explicit --service/--account/--keychain cannot be combined" - ) - } - - guard let parsed = SecretReference(string: reference)?.locator else { - throw SecretsError.invalidReference(reference) - } - return parsed - } - - guard let service, !service.isEmpty else { - throw SecretsError.invalidInput("missing --service") - } - guard let account, !account.isEmpty else { - throw SecretsError.invalidInput("missing --account") - } - return SecretLocator(service: service, account: account, keychain: keychain) - } - static func parseRunInvocation(_ arguments: [String]) throws -> RunInvocation { var bindings: [RunInvocation.Binding] = [] var index = 0 @@ -661,13 +534,6 @@ private extension SecretsCommand { var updated: Bool } - struct ReferencePayload: Encodable { - var reference: String - var service: String - var account: String - var keychain: String? - } - struct GetPayload: Encodable { var reference: String var service: String diff --git a/Sources/BashSecrets/SecretsReference.swift b/Sources/BashSecrets/SecretsReference.swift index 2b054c6..f578ca9 100644 --- a/Sources/BashSecrets/SecretsReference.swift +++ b/Sources/BashSecrets/SecretsReference.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation public struct SecretLocator: Sendable, Hashable, Codable { @@ -12,43 +13,55 @@ public struct SecretLocator: Sendable, Hashable, Codable { } } -public struct SecretReference: Sendable, Hashable, Codable { - public static let prefix = "secretref:v1:" +enum SecretReference { + static let prefix = "secretref:" - public var locator: SecretLocator - - public init(locator: SecretLocator) { - self.locator = locator - } - - public var stringValue: String { - let payload = Payload(kind: "generic-password", locator: locator) + static func makeGenericPasswordReference( + locator: SecretLocator, + using key: SymmetricKey + ) throws -> String { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] - guard - let encoded = try? encoder.encode(payload) - else { - return Self.prefix + let payload = try encoder.encode(Payload(kind: "generic-password", locator: locator)) + let sealed = try AES.GCM.seal(payload, using: key) + guard let combined = sealed.combined else { + throw SecretsError.runtimeFailure("failed to create secret reference") } - - return Self.prefix + encoded.base64URLEncodedString() + return prefix + combined.base64URLEncodedString() } - public init?(string: String) { - guard string.hasPrefix(Self.prefix) else { - return nil + static func parseGenericPasswordReference( + _ value: String, + using key: SymmetricKey + ) throws -> SecretLocator { + guard value.hasPrefix(prefix) else { + throw SecretsError.invalidReference(value) + } + + let rawPayload = String(value.dropFirst(prefix.count)) + guard let sealedData = Data(base64URLEncoded: rawPayload) else { + throw SecretsError.invalidReference(value) } - let rawPayload = String(string.dropFirst(Self.prefix.count)) - guard - let payloadData = Data(base64URLEncoded: rawPayload), - let payload = try? JSONDecoder().decode(Payload.self, from: payloadData), - payload.kind == "generic-password" - else { - return nil + let payloadData: Data + do { + let sealedBox = try AES.GCM.SealedBox(combined: sealedData) + payloadData = try AES.GCM.open(sealedBox, using: key) + } catch { + throw SecretsError.invalidReference(value) } - locator = payload.locator + do { + let payload = try JSONDecoder().decode(Payload.self, from: payloadData) + guard payload.kind == "generic-password" else { + throw SecretsError.invalidReference(value) + } + return payload.locator + } catch let error as SecretsError { + throw error + } catch { + throw SecretsError.invalidReference(value) + } } private struct Payload: Codable { @@ -57,6 +70,12 @@ public struct SecretReference: Sendable, Hashable, Codable { } } +extension SymmetricKey { + var rawData: Data { + withUnsafeBytes { Data($0) } + } +} + private extension Data { func base64URLEncodedString() -> String { base64EncodedString() diff --git a/Sources/BashSecrets/SecretsRuntime.swift b/Sources/BashSecrets/SecretsRuntime.swift index c7f8d44..b963885 100644 --- a/Sources/BashSecrets/SecretsRuntime.swift +++ b/Sources/BashSecrets/SecretsRuntime.swift @@ -20,111 +20,26 @@ public struct SecretFetchResult: Sendable { } } -public protocol SecretsRuntime: Sendable { +public protocol SecretsProvider: Sendable { func putGenericPassword( locator: SecretLocator, value: Data, label: String?, update: Bool - ) async throws + ) async throws -> String + + func reference(for locator: SecretLocator) async throws -> String func getGenericPassword( - locator: SecretLocator, + reference: String, revealValue: Bool ) async throws -> SecretFetchResult - func deleteGenericPassword(locator: SecretLocator) async throws -> Bool -} - -public enum SecretsError: Error, CustomStringConvertible, Sendable { - case invalidInput(String) - case invalidReference(String) - case notFound(SecretLocator) - case duplicateItem(SecretLocator) - case unsupported(String) - case runtimeFailure(String) - - public var description: String { - switch self { - case let .invalidInput(message): - return message - case let .invalidReference(value): - return "invalid secret reference: \(value)" - case let .notFound(locator): - return "secret not found for service '\(locator.service)' and account '\(locator.account)'" - case let .duplicateItem(locator): - return "secret already exists for service '\(locator.service)' and account '\(locator.account)'" - case let .unsupported(message): - return message - case let .runtimeFailure(message): - return message - } - } -} - -public actor SecretsRuntimeRegistry { - public static let shared = SecretsRuntimeRegistry() - - private var runtime: any SecretsRuntime - - public init(runtime: (any SecretsRuntime)? = nil) { - if let runtime { - self.runtime = runtime - return - } - - #if canImport(Security) - self.runtime = AppleKeychainSecretsRuntime() - #else - self.runtime = UnsupportedSecretsRuntime( - message: "keychain secrets are not supported on this platform" - ) - #endif - } - - public func setRuntime(_ runtime: any SecretsRuntime) { - self.runtime = runtime - } - - public func currentRuntime() -> any SecretsRuntime { - runtime - } - - public func resetToDefault() { - #if canImport(Security) - runtime = AppleKeychainSecretsRuntime() - #else - runtime = UnsupportedSecretsRuntime( - message: "keychain secrets are not supported on this platform" - ) - #endif - } + func deleteReference(_ reference: String) async throws -> Bool } -public enum Secrets { - public static func setRuntime(_ runtime: any SecretsRuntime) async { - await SecretsRuntimeRegistry.shared.setRuntime(runtime) - } - - public static func resetRuntime() async { - await SecretsRuntimeRegistry.shared.resetToDefault() - } - - public static func makeReference( - service: String, - account: String, - keychain: String? = nil - ) -> String { - SecretReference( - locator: SecretLocator(service: service, account: account, keychain: keychain) - ).stringValue - } - - public static func parseReference(_ value: String) -> SecretLocator? { - SecretReference(string: value)?.locator - } - - public static func putGenericPassword( +public extension SecretsProvider { + func putGenericPassword( service: String, account: String, keychain: String? = nil, @@ -132,52 +47,67 @@ public enum Secrets { label: String? = nil, update: Bool = true ) async throws -> String { - let locator = SecretLocator(service: service, account: account, keychain: keychain) - let runtime = await SecretsRuntimeRegistry.shared.currentRuntime() - try await runtime.putGenericPassword( - locator: locator, + try await putGenericPassword( + locator: SecretLocator(service: service, account: account, keychain: keychain), value: value, label: label, update: update ) - return SecretReference(locator: locator).stringValue } - public static func metadata(forReference reference: String) async throws -> SecretMetadata { - guard let locator = parseReference(reference) else { - throw SecretsError.invalidReference(reference) - } - let runtime = await SecretsRuntimeRegistry.shared.currentRuntime() - return try await runtime.getGenericPassword( - locator: locator, - revealValue: false - ).metadata + func reference( + service: String, + account: String, + keychain: String? = nil + ) async throws -> String { + try await reference( + for: SecretLocator(service: service, account: account, keychain: keychain) + ) } - public static func resolveReference(_ reference: String) async throws -> Data { - guard let locator = parseReference(reference) else { - throw SecretsError.invalidReference(reference) - } - let runtime = await SecretsRuntimeRegistry.shared.currentRuntime() - let fetched = try await runtime.getGenericPassword(locator: locator, revealValue: true) + func metadata(forReference reference: String) async throws -> SecretMetadata { + try await getGenericPassword(reference: reference, revealValue: false).metadata + } + + func resolveReference(_ reference: String) async throws -> Data { + let fetched = try await getGenericPassword(reference: reference, revealValue: true) guard let value = fetched.value else { + let locator = fetched.metadata.locator throw SecretsError.runtimeFailure( "secret value missing for service '\(locator.service)' and account '\(locator.account)'" ) } return value } +} - public static func deleteReference(_ reference: String) async throws -> Bool { - guard let locator = parseReference(reference) else { - throw SecretsError.invalidReference(reference) +public enum SecretsError: Error, CustomStringConvertible, Sendable { + case invalidInput(String) + case invalidReference(String) + case notFound(SecretLocator) + case duplicateItem(SecretLocator) + case unsupported(String) + case runtimeFailure(String) + + public var description: String { + switch self { + case let .invalidInput(message): + return message + case let .invalidReference(value): + return "invalid secret reference: \(value)" + case let .notFound(locator): + return "secret not found for service '\(locator.service)' and account '\(locator.account)'" + case let .duplicateItem(locator): + return "secret already exists for service '\(locator.service)' and account '\(locator.account)'" + case let .unsupported(message): + return message + case let .runtimeFailure(message): + return message } - let runtime = await SecretsRuntimeRegistry.shared.currentRuntime() - return try await runtime.deleteGenericPassword(locator: locator) } } -struct UnsupportedSecretsRuntime: SecretsRuntime { +struct UnsupportedSecretsProvider: SecretsProvider { let message: String func putGenericPassword( @@ -185,7 +115,7 @@ struct UnsupportedSecretsRuntime: SecretsRuntime { value: Data, label: String?, update: Bool - ) async throws { + ) async throws -> String { _ = locator _ = value _ = label @@ -193,17 +123,22 @@ struct UnsupportedSecretsRuntime: SecretsRuntime { throw SecretsError.unsupported(message) } + func reference(for locator: SecretLocator) async throws -> String { + _ = locator + throw SecretsError.unsupported(message) + } + func getGenericPassword( - locator: SecretLocator, + reference: String, revealValue: Bool ) async throws -> SecretFetchResult { - _ = locator + _ = reference _ = revealValue throw SecretsError.unsupported(message) } - func deleteGenericPassword(locator: SecretLocator) async throws -> Bool { - _ = locator + func deleteReference(_ reference: String) async throws -> Bool { + _ = reference throw SecretsError.unsupported(message) } } diff --git a/Tests/BashSecretsTests/SecretsCommandTests.swift b/Tests/BashSecretsTests/SecretsCommandTests.swift index a7e916a..fe9649a 100644 --- a/Tests/BashSecretsTests/SecretsCommandTests.swift +++ b/Tests/BashSecretsTests/SecretsCommandTests.swift @@ -13,6 +13,7 @@ struct SecretsCommandTests { let help = await session.run("secrets --help") #expect(help.exitCode == 0) #expect(help.stdoutString.contains("USAGE: secrets")) + #expect(!help.stdoutString.contains("\n ref")) let subcommandHelp = await session.run("secrets put --help") #expect(subcommandHelp.exitCode == 0) @@ -35,7 +36,7 @@ struct SecretsCommandTests { #expect(put.exitCode == 0) let reference = put.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) - #expect(reference.hasPrefix("secretref:v1:")) + #expect(reference.hasPrefix("secretref:")) let get = await session.run("secrets get \(reference)") #expect(get.exitCode == 0) @@ -65,24 +66,21 @@ struct SecretsCommandTests { #expect(reveal.stdoutString == secret) } - @Test("ref and delete flow") - func refAndDeleteFlow() async throws { + @Test("delete flow uses references only") + func deleteFlowUsesReferencesOnly() async throws { let (session, root) = try await SecretsTestSupport.makeSession() defer { SecretsTestSupport.removeDirectory(root) } let service = "svc-\(UUID().uuidString)" let account = "acct-\(UUID().uuidString)" - _ = await session.run( + let put = await session.run( "secrets put --service \(service) --account \(account)", stdin: Data("value".utf8) ) + #expect(put.exitCode == 0) - let ref = await session.run("secrets ref --service \(service) --account \(account)") - #expect(ref.exitCode == 0) - let reference = ref.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) - #expect(reference.hasPrefix("secretref:v1:")) - + let reference = put.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) let delete = await session.run("secrets delete \(reference)") #expect(delete.exitCode == 0) @@ -119,7 +117,7 @@ struct SecretsCommandTests { let (session, root) = try await SecretsTestSupport.makeSession() defer { SecretsTestSupport.removeDirectory(root) } - let missingDelimiter = await session.run("secrets run --env API_TOKEN=secretref:v1:abc printenv API_TOKEN") + let missingDelimiter = await session.run("secrets run --env API_TOKEN=secretref:abc printenv API_TOKEN") #expect(missingDelimiter.exitCode == 2) #expect(missingDelimiter.stderrString.contains("expected --env or --")) @@ -128,32 +126,45 @@ struct SecretsCommandTests { #expect(missingBinding.stderrString.contains("at least one --env binding")) } - @Test("Secrets API resolves references without shell output") - func secretsAPIResolvesReferencesWithoutShellOutput() async throws { - await Secrets.setRuntime(InMemorySecretsRuntime.shared) - + @Test("provider API resolves references without shell output") + func providerAPIResolvesReferencesWithoutShellOutput() async throws { + let provider = InMemorySecretsProvider() let service = "svc-\(UUID().uuidString)" let account = "acct-\(UUID().uuidString)" let secret = Data("api-secret-\(UUID().uuidString)".utf8) - let reference = try await Secrets.putGenericPassword( + let reference = try await provider.putGenericPassword( service: service, account: account, value: secret ) - #expect(reference.hasPrefix("secretref:v1:")) + #expect(reference.hasPrefix("secretref:")) - let metadata = try await Secrets.metadata(forReference: reference) + let metadata = try await provider.metadata(forReference: reference) #expect(metadata.locator.service == service) #expect(metadata.locator.account == account) - let resolved = try await Secrets.resolveReference(reference) + let resolved = try await provider.resolveReference(reference) #expect(resolved == secret) - let deleted = try await Secrets.deleteReference(reference) + let deleted = try await provider.deleteReference(reference) #expect(deleted) } + @Test("malformed references are rejected") + func malformedReferencesAreRejected() async throws { + let (session, root) = try await SecretsTestSupport.makeSession() + defer { SecretsTestSupport.removeDirectory(root) } + + let invalidGet = await session.run("secrets get secretref:not-a-valid-reference") + #expect(invalidGet.exitCode == 2) + #expect(invalidGet.stderrString.contains("invalid secret reference")) + + let invalidDelete = await session.run("secrets delete secretref:###") + #expect(invalidDelete.exitCode == 2) + #expect(invalidDelete.stderrString.contains("invalid secret reference")) + } + @Test("strict policy blocks secrets get --reveal") func strictPolicyBlocksReveal() async throws { let (session, root) = try await SecretsTestSupport.makeSecretAwareSession(policy: .strict) @@ -190,6 +201,68 @@ struct SecretsCommandTests { #expect(!run.stdoutString.contains(secretValue)) } + @Test("provider can be shared across sessions for durable refs") + func providerCanBeSharedAcrossSessionsForDurableRefs() async throws { + let provider = InMemorySecretsProvider() + let root1 = try SecretsTestSupport.makeTempDirectory(prefix: "BashSecretsTests-A") + let root2 = try SecretsTestSupport.makeTempDirectory(prefix: "BashSecretsTests-B") + defer { + SecretsTestSupport.removeDirectory(root1) + SecretsTestSupport.removeDirectory(root2) + } + + let session1 = try await SecretsTestSupport.makeSecretAwareSession( + provider: provider, + policy: .resolveAndRedact, + root: root1 + ) + let session2 = try await SecretsTestSupport.makeSecretAwareSession( + provider: provider, + policy: .resolveAndRedact, + root: root2 + ) + + let put = await session1.run( + "secrets put --service shared-service --account shared-account", + stdin: Data("shared-secret".utf8) + ) + #expect(put.exitCode == 0) + let reference = put.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) + + let get = await session2.run("secrets get \(reference)") + #expect(get.exitCode == 0) + #expect(get.stdoutString.contains("service=shared-service")) + } + + @Test("references are scoped to their provider") + func referencesAreScopedToTheirProvider() async throws { + let providerA = InMemorySecretsProvider() + let providerB = InMemorySecretsProvider() + let (sessionA, rootA) = try await SecretsTestSupport.makeSecretAwareSession( + provider: providerA, + policy: .resolveAndRedact + ) + let (sessionB, rootB) = try await SecretsTestSupport.makeSecretAwareSession( + provider: providerB, + policy: .resolveAndRedact + ) + defer { + SecretsTestSupport.removeDirectory(rootA) + SecretsTestSupport.removeDirectory(rootB) + } + + let put = await sessionA.run( + "secrets put --service scoped-service --account scoped-account", + stdin: Data("scoped-secret".utf8) + ) + #expect(put.exitCode == 0) + let reference = put.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) + + let get = await sessionB.run("secrets get \(reference)") + #expect(get.exitCode == 2) + #expect(get.stderrString.contains("invalid secret reference")) + } + @Test("keychain scope keeps service and account entries isolated") func keychainScopeKeepsEntriesIsolated() async throws { let (session, root) = try await SecretsTestSupport.makeSession() @@ -218,20 +291,13 @@ struct SecretsCommandTests { #expect(referenceA != referenceB) - let getA = await session.run("secrets get --service \(service) --account \(account) --keychain \(keychainA) --reveal") + let getA = await session.run("secrets get --reveal \(referenceA)") #expect(getA.exitCode == 0) #expect(getA.stdoutString == secretA) - let getB = await session.run("secrets get --service \(service) --account \(account) --keychain \(keychainB) --reveal") + let getB = await session.run("secrets get --reveal \(referenceB)") #expect(getB.exitCode == 0) #expect(getB.stdoutString == secretB) - - let deleteA = await session.run("secrets delete \(referenceA)") - #expect(deleteA.exitCode == 0) - - let stillB = await session.run("secrets get \(referenceB) --reveal") - #expect(stillB.exitCode == 0) - #expect(stillB.stdoutString == secretB) } @Test("json output payloads are structured and complete") @@ -254,15 +320,7 @@ struct SecretsCommandTests { #expect(putPayload.account == account) #expect(putPayload.keychain == keychain) #expect(!putPayload.updated) - #expect(putPayload.reference.hasPrefix("secretref:v1:")) - - let ref = await session.run("secrets ref --service \(service) --account \(account) --keychain \(keychain) --json") - #expect(ref.exitCode == 0) - let refPayload: ReferenceJSONPayload = try decodeJSON(ref.stdoutString) - #expect(refPayload.reference == putPayload.reference) - #expect(refPayload.service == service) - #expect(refPayload.account == account) - #expect(refPayload.keychain == keychain) + #expect(putPayload.reference.hasPrefix("secretref:")) let get = await session.run("secrets get \(putPayload.reference) --json") #expect(get.exitCode == 0) @@ -302,7 +360,7 @@ struct SecretsCommandTests { ) #expect(update.exitCode == 0) - let reveal = await session.run("secrets get \(reference) --reveal") + let reveal = await session.run("secrets get --reveal \(reference)") #expect(reveal.exitCode == 0) #expect(reveal.stdoutString == "second-value") @@ -317,39 +375,27 @@ struct SecretsCommandTests { #expect(missing.stderrString.contains("not found")) } - @Test("invalid or conflicting references return usage failures") - func invalidOrConflictingReferencesReturnUsageFailures() async throws { + @Test("shell access to existing secrets is ref only") + func shellAccessToExistingSecretsIsRefOnly() async throws { let (session, root) = try await SecretsTestSupport.makeSession() defer { SecretsTestSupport.removeDirectory(root) } - let invalidGet = await session.run("secrets get secretref:v1:not-a-valid-reference") - #expect(invalidGet.exitCode == 2) - #expect(invalidGet.stderrString.contains("invalid secret reference")) - - let invalidDelete = await session.run("secrets delete secretref:v1:###") - #expect(invalidDelete.exitCode == 2) - #expect(invalidDelete.stderrString.contains("invalid secret reference")) - - let put = await session.run( - "secrets put --service conflict-service --account conflict-account", - stdin: Data("value".utf8) - ) - #expect(put.exitCode == 0) - let reference = put.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) + let get = await session.run("secrets get --service app --account api") + #expect(get.exitCode != 0) + #expect(get.stderrString.contains("--service")) - let conflicting = await session.run( - "secrets get \(reference) --service conflict-service --account conflict-account" - ) - #expect(conflicting.exitCode == 2) - #expect(conflicting.stderrString.contains("cannot be combined")) + let delete = await session.run("secrets delete --service app --account api") + #expect(delete.exitCode != 0) + #expect(delete.stderrString.contains("--service")) } - @Test("run rejects non-UTF-8 secrets when injecting env vars") + @Test("run rejects non-UTF8 secrets when injecting env vars") func runRejectsNonUTF8SecretsWhenInjectingEnvVars() async throws { - let (session, root) = try await SecretsTestSupport.makeSession() + let provider = InMemorySecretsProvider() + let (session, root) = try await SecretsTestSupport.makeSession(provider: provider) defer { SecretsTestSupport.removeDirectory(root) } - let reference = try await Secrets.putGenericPassword( + let reference = try await provider.putGenericPassword( service: "bin-\(UUID().uuidString)", account: "blob-\(UUID().uuidString)", value: Data([0xFF, 0x00, 0xFE]) @@ -360,8 +406,8 @@ struct SecretsCommandTests { #expect(run.stderrString.contains("not UTF-8")) } - @Test("resolve and redact policy covers stderr pipelines and redirections") - func resolveAndRedactPolicyCoversStderrPipelinesAndRedirections() async throws { + @Test("protected mode redacts caller output but not redirected files") + func protectedModeRedactsCallerOutputButNotRedirectedFiles() async throws { let (session, root) = try await SecretsTestSupport.makeSecretAwareSession(policy: .resolveAndRedact) defer { SecretsTestSupport.removeDirectory(root) } @@ -385,17 +431,41 @@ struct SecretsCommandTests { let stdoutRedirect = await session.run("secrets run --env TOKEN=\(reference) -- printenv TOKEN > token.txt") #expect(stdoutRedirect.exitCode == 0) + #expect(stdoutRedirect.stdoutString.isEmpty) let tokenFile = await session.run("cat token.txt") #expect(tokenFile.exitCode == 0) - #expect(tokenFile.stdoutString == "\(reference)\n") - #expect(!tokenFile.stdoutString.contains(secretValue)) + #expect(tokenFile.stdoutString == "\(secretValue)\n") let stderrRedirect = await session.run("secrets run --env TOKEN=\(reference) -- \(secretValue) 2> error.txt") #expect(stderrRedirect.exitCode == 127) + #expect(stderrRedirect.stderrString.isEmpty) let errorFile = await session.run("cat error.txt") #expect(errorFile.exitCode == 0) - #expect(errorFile.stdoutString.contains("\(reference): command not found")) - #expect(!errorFile.stdoutString.contains(secretValue)) + #expect(errorFile.stdoutString.contains("\(secretValue): command not found")) + } + + @Test("export and expansion keep opaque references") + func exportAndExpansionKeepOpaqueReferences() async throws { + let (session, root) = try await SecretsTestSupport.makeSecretAwareSession(policy: .resolveAndRedact) + defer { SecretsTestSupport.removeDirectory(root) } + + let put = await session.run( + "secrets put --service export-service --account export-account", + stdin: Data("export-secret".utf8) + ) + #expect(put.exitCode == 0) + let reference = put.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) + + let export = await session.run("export TOKEN=\(reference)") + #expect(export.exitCode == 0) + + let printenv = await session.run("printenv TOKEN") + #expect(printenv.exitCode == 0) + #expect(printenv.stdoutString == "\(reference)\n") + + let echo = await session.run("echo $TOKEN") + #expect(echo.exitCode == 0) + #expect(echo.stdoutString == "\(reference)\n") } @Test("curl resolves secret refs and redacts verbose output") @@ -436,13 +506,6 @@ struct SecretsCommandTests { var updated: Bool } - private struct ReferenceJSONPayload: Decodable { - var reference: String - var service: String - var account: String - var keychain: String? - } - private struct GetJSONPayload: Decodable { var reference: String var service: String diff --git a/Tests/BashSecretsTests/TestSupport.swift b/Tests/BashSecretsTests/TestSupport.swift index 27ea725..89e5210 100644 --- a/Tests/BashSecretsTests/TestSupport.swift +++ b/Tests/BashSecretsTests/TestSupport.swift @@ -11,76 +11,49 @@ enum SecretsTestSupport { } static func makeSession( + provider: InMemorySecretsProvider = InMemorySecretsProvider(), options: SessionOptions = SessionOptions(filesystem: ReadWriteFilesystem(), layout: .unixLike) ) async throws -> (session: BashSession, root: URL) { - await Secrets.setRuntime(InMemorySecretsRuntime.shared) let root = try makeTempDirectory() let session = try await BashSession(rootDirectory: root, options: options) - await session.registerSecrets() + await session.registerSecrets(provider: provider) return (session, root) } static func makeSecretAwareSession( + provider: InMemorySecretsProvider = InMemorySecretsProvider(), policy: SecretHandlingPolicy, networkPolicy: ShellNetworkPolicy = .disabled ) async throws -> (session: BashSession, root: URL) { - try await makeSession( + let root = try makeTempDirectory() + let session = try await makeSecretAwareSession( + provider: provider, + policy: policy, + networkPolicy: networkPolicy, + root: root + ) + return (session, root) + } + + static func makeSecretAwareSession( + provider: InMemorySecretsProvider = InMemorySecretsProvider(), + policy: SecretHandlingPolicy, + networkPolicy: ShellNetworkPolicy = .disabled, + root: URL + ) async throws -> BashSession { + let session = try await BashSession( + rootDirectory: root, options: SessionOptions( filesystem: ReadWriteFilesystem(), layout: .unixLike, - networkPolicy: networkPolicy, - secretPolicy: policy, - secretResolver: BashSecretsReferenceResolver() + networkPolicy: networkPolicy ) ) + await session.registerSecrets(provider: provider, policy: policy) + return session } static func removeDirectory(_ url: URL) { try? FileManager.default.removeItem(at: url) } } - -actor InMemorySecretsRuntime: SecretsRuntime { - static let shared = InMemorySecretsRuntime() - - private struct StoredValue { - var metadata: SecretMetadata - var value: Data - } - - private var values: [SecretLocator: StoredValue] = [:] - - func putGenericPassword( - locator: SecretLocator, - value: Data, - label: String?, - update: Bool - ) async throws { - if values[locator] != nil, !update { - throw SecretsError.duplicateItem(locator) - } - - values[locator] = StoredValue( - metadata: SecretMetadata(locator: locator, label: label), - value: value - ) - } - - func getGenericPassword( - locator: SecretLocator, - revealValue: Bool - ) async throws -> SecretFetchResult { - guard let stored = values[locator] else { - throw SecretsError.notFound(locator) - } - - return SecretFetchResult( - metadata: stored.metadata, - value: revealValue ? stored.value : nil - ) - } - - func deleteGenericPassword(locator: SecretLocator) async throws -> Bool { - values.removeValue(forKey: locator) != nil - } -} From d8b277ff135a06e5710a9a951d9ffac7479e6c86 Mon Sep 17 00:00:00 2001 From: Zac White Date: Mon, 23 Mar 2026 12:20:00 -0700 Subject: [PATCH 12/14] Fixed README doc issues --- AGENTS.md | 235 ------------------------------------ README.md | 56 ++++----- docs/command-parity-gaps.md | 2 +- 3 files changed, 29 insertions(+), 264 deletions(-) delete mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 48b21f5..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,235 +0,0 @@ -# AGENTS.md - -This file is a contributor guide for `Bash`. - -## Project Goal - -`Bash` is an in-process, stateful, emulated shell for Swift apps. - -Key properties: -- Shell commands run inside Swift (no subprocess spawning). -- Session state persists across runs (`cwd`, environment, history). -- Commands mutate a pluggable filesystem abstraction. -- Shell behavior is practical and test-first for app and LLM use-cases. - -## Tech + Platform Baseline - -- Swift tools: `6.2` -- Package: SwiftPM library products `Bash`, `BashSQLite` (optional), `BashPython` (optional), `BashGit` (optional) -- Parsing/help: [`swift-argument-parser`](https://github.com/apple/swift-argument-parser) -- Tests: Swift Testing (`import Testing`), not XCTest -- Package platforms: - - macOS 13+ - - iOS 16+ - - tvOS 16+ - - watchOS 9+ - -## Core Architecture - -### High-level flow - -1. `BashSession.run(_:)` receives a command line. -2. `ShellLexer` tokenizes input (quotes, escapes, operators). -3. `ShellParser` builds a parsed line (pipelines + chain segments + redirections). -4. `ShellExecutor` executes: - - variable expansion - - optional glob expansion - - pipeline plumbing (`stdout` -> next `stdin`) - - redirections (`>`, `>>`, `<`, `2>`, `2>&1`) - - chain short-circuiting (`&&`, `||`, `;`) -5. `BashSession` persists updated state and returns `CommandResult`. - -### Session + state - -Primary entry point: -- `Sources/Bash/BashSession.swift` - -Important behavior: -- `BashSession` is an `actor`. -- `run` returns `CommandResult` even on command failures. -- Parser/runtime faults surface as `exitCode = 2` with stderr text. -- Unknown commands return `127`. -- Default layout `.unixLike` scaffolds `/home/user`, `/bin`, `/usr/bin`, `/tmp`. -- Built-ins are registered at startup and stubs are created under `/bin` and `/usr/bin`. - -### Command abstraction - -Command interface: -- `Sources/Bash/Commands/BuiltinCommand.swift` - -Pattern: -- Each command conforms to `BuiltinCommand`. -- Options are `ParsableArguments`. -- `--help` and argument validation come from ArgumentParser. -- Runtime receives mutable `CommandContext` (stdin/stdout/stderr + env/cwd/filesystem). - -## Command Code Map - -Registration list: -- `Sources/Bash/Commands/DefaultCommands.swift` - -Shared helpers: -- `Sources/Bash/Commands/CommandSupport.swift` - -File operation commands: -- `Sources/Bash/Commands/File/BasicFileCommands.swift` - - `cat`, `readlink`, `rm`, `stat`, `touch` -- `Sources/Bash/Commands/File/CopyMoveLinkCommands.swift` - - `cp`, `ln`, `mv` -- `Sources/Bash/Commands/File/DirectoryCommands.swift` - - `ls`, `mkdir`, `rmdir` -- `Sources/Bash/Commands/File/MetadataCommands.swift` - - `chmod`, `file` -- `Sources/Bash/Commands/File/TreeCommand.swift` - - `tree` - -Text/search/transform commands: -- `Sources/Bash/Commands/Text/DiffCommand.swift` - - `diff` -- `Sources/Bash/Commands/Text/SearchCommands.swift` - - `grep` (+ aliases), `rg` -- `Sources/Bash/Commands/Text/LineCommands.swift` - - `head`, `tail`, `wc` -- `Sources/Bash/Commands/Text/TransformCommands.swift` - - `sort`, `uniq`, `cut`, `tr` -- `Sources/Bash/Commands/Text/AwkCommand.swift` - - `awk` -- `Sources/Bash/Commands/Text/SedCommand.swift` - - `sed` -- `Sources/Bash/Commands/Text/XargsCommand.swift` - - `xargs` - -Formatting/hash commands: -- `Sources/Bash/Commands/FormattingCommands.swift` - - `printf`, `base64`, `sha256sum`, `sha1sum`, `md5sum` - -Compression/archive commands: -- `Sources/Bash/Commands/CompressionCommands.swift` - - `gzip`, `gunzip`, `zcat`, `zip`, `unzip`, `tar` - -Data processing commands: -- `Sources/Bash/Commands/DataCommands.swift` - - `jq`, `yq`, `xan` - -Navigation/environment commands: -- `Sources/Bash/Commands/NavigationCommands.swift` - - `basename`, `cd`, `dirname`, `du`, `echo`, `env`, `export`, `find`, `printenv`, `pwd`, `tee` - -Utility commands: -- `Sources/Bash/Commands/UtilityCommands.swift` - - `clear`, `date`, `hostname`, `false`, `whoami`, `help`, `history`, `seq`, `sleep`, `time`, `timeout`, `true`, `which` - -Network commands: -- `Sources/Bash/Commands/NetworkCommands.swift` - - `curl` -- `Sources/Bash/Commands/Network/HtmlToMarkdownCommand.swift` - - `html-to-markdown` - -Optional module commands: -- `Sources/BashSQLite/*` - - `sqlite3` (register via `BashSession.registerSQLite3()`) -- `Sources/BashPython/*` - - `python3`, `python` alias (register via `BashSession.registerPython()` / `registerPython3()`) - - runtime abstraction: `PythonRuntime`, default `PyodideRuntime` -- `Sources/BashGit/*` - - `git` subset (register via `BashSession.registerGit()`) - - runtime/interop: `GitEngine.swift` + `Clibgit2` SwiftPM binary target (`Clibgit2.xcframework`) - -## Filesystem Architecture - -Filesystem protocol: -- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/FS/WorkspaceFilesystem.swift` - -Implementations: -- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/FS/ReadWriteFilesystem.swift` - - Real disk I/O with jail to configured root. -- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/FS/InMemoryFilesystem.swift` - - Pure in-memory tree. -- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/FS/OverlayFilesystem.swift` - - Copy-on-write snapshot of a configured disk root, with explicit `reload()` support. -- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/FS/MountableFilesystem.swift` - - Composes multiple filesystem backends under virtual mount points. -- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/FS/SandboxFilesystem.swift` - - Root chooser (`documents`, `caches`, `temporary`, app group, custom URL), delegates to read-write backing. -- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/FS/SecurityScopedFilesystem.swift` - - Security-scoped URL/bookmark-backed root, optional read-only mode, runtime unsupported on tvOS/watchOS. - -Bookmark persistence: -- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/FS/BookmarkStore.swift` -- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/FS/UserDefaultsBookmarkStore.swift` - -Path + jail utilities: -- `/Users/zac/Projects/collab/Workspace/Sources/Workspace/Core/PathUtils.swift` - -## Parser + Executor Source Map - -- `Sources/Bash/Core/ShellLexer.swift` -- `Sources/Bash/Core/ShellParser.swift` -- `Sources/Bash/Core/ShellExecutor.swift` - -Current shell language scope (implemented): -- Pipes: `|` -- Redirections: `>`, `>>`, `<`, `2>`, `2>&1` -- Chains: `&&`, `||`, `;` -- Variable expansion: `$VAR`, `${VAR}`, `${VAR:-default}` -- Globs: `*`, `?`, `[abc]` when enabled -- Path-like command invocation (`/bin/ls`, etc.) - -Not in scope yet: -- shell control flow (`if`, `for`, `while`, functions, positional shell params) - -## Testing Structure - -All tests are Swift Testing suites: -- `Tests/BashTests/ParserAndFilesystemTests.swift` -- `Tests/BashTests/SessionIntegrationTests.swift` -- `Tests/BashTests/CommandCoverageTests.swift` -- `Tests/BashTests/FilesystemOptionsTests.swift` -- `Tests/BashTests/TestSupport.swift` -- `Tests/BashSQLiteTests/*` -- `Tests/BashPythonTests/*` -- `Tests/BashGitTests/*` - -Coverage style: -- parser/lexer unit behavior -- filesystem safety and platform behavior -- command integration flows -- `--help` and invalid-flag coverage for built-ins - -Run: -- `swift test` - -## Parity Tracking - -Command parity gaps vs `just-bash` are tracked in: -- `docs/command-parity-gaps.md` - -When you add or upgrade command behavior: -1. Update the relevant command entry in `docs/command-parity-gaps.md`. -2. Lower the command priority only after tests are added for the newly closed gap. - -## Contributor Rules of Thumb - -When adding or changing a command: -1. Place it in the correct file family above (or create a new focused file if needed). -2. Keep command implementation in-process; do not shell out to host binaries. -3. Use `ParsableArguments` options so `--help` works automatically. -4. Return shell-like exit codes (`0` success, non-zero for command failure, `2` for usage/parser-style errors where appropriate). -5. Resolve paths through `CommandContext.resolvePath` and filesystem APIs; do not bypass jail semantics. -6. Preserve `stdin`/`stdout`/`stderr` behavior for pipelines and redirections. -7. Add/adjust tests in `SessionIntegrationTests` and `CommandCoverageTests`. -8. Keep cross-platform compilation intact; prefer runtime `ShellError.unsupported(...)` over compile-time API breakage for platform-limited behavior. - -When adding filesystem implementations: -1. Conform to `ShellFilesystem`. -2. Ensure the filesystem is ready to use before passing it into `BashSession(options:)`; do not rely on hidden session setup. -3. Keep path normalization and jail guarantees explicit and tested. -4. Add platform-conditional tests in `FilesystemOptionsTests`. - -## Command Registry Update Checklist - -If you add a new built-in command: -1. Add the command type to `defaults` in `Sources/Bash/Commands/DefaultCommands.swift`. -2. Add it to the coverage list in `Tests/BashTests/CommandCoverageTests.swift`. -3. Add integration tests for at least one success and one failure/edge case. -4. Ensure `--help` output works and invalid flag behavior is non-zero. diff --git a/README.md b/README.md index 709c26b..d688a3b 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ print(py.stdoutString) // hi On other Apple platforms, including iOS/iPadOS, Mac Catalyst, tvOS, and watchOS, the module still compiles but runtime execution returns an unavailable error. Maintainer notes for the broader Apple runtime plan live in [`docs/cpython-apple-runtime.md`](docs/cpython-apple-runtime.md). -Strict filesystem mode is enabled by default. Script-visible file APIs are shimmed through `ShellFilesystem`, so Python file operations share the same jailed root as shell commands. +Strict filesystem mode is enabled by default. Script-visible file APIs are shimmed through the reexported `FileSystem` layer, so Python file operations share the same jailed root as shell commands. Blocked escape APIs include `subprocess`, `ctypes`, and process-spawn helpers like `os.system` / `os.popen` / `os.spawn*`. `SessionOptions.networkPolicy` and `permissionHandler` also apply to Python socket connections, so host apps can enforce the same outbound rules across shell commands and embedded Python. `pip` and arbitrary native extension loading are non-goals in this runtime profile. @@ -207,9 +207,9 @@ Import `Workspace` from that package directly when you want workspace tooling wi ```swift import Workspace -let filesystem = PermissionedWorkspaceFilesystem( +let filesystem = PermissionedFileSystem( base: try OverlayFilesystem(rootDirectory: workspaceRoot), - authorizer: WorkspacePermissionAuthorizer { request in + authorizer: PermissionAuthorizer { request in switch request.operation { case .readFile, .listDirectory, .stat: return .allowForSession @@ -285,25 +285,25 @@ public struct ExecutionLimits { Each `run` executes under an `ExecutionLimits` budget. Exceeding a structural limit stops execution with exit code `2`. If `maxWallClockDuration` is exceeded, execution stops with exit code `124`. If `cancellationCheck` returns `true`, or the surrounding task is cancelled, execution stops with exit code `130`. Wall-clock accounting excludes time spent waiting on host permission callbacks. -### `PermissionRequest` and `PermissionDecision` +### `ShellPermissionRequest` and `ShellPermissionDecision` ```swift -public struct PermissionRequest { +public struct ShellPermissionRequest { public enum Kind { - case network(NetworkPermissionRequest) - case filesystem(FilesystemPermissionRequest) + case network(ShellNetworkPermissionRequest) + case filesystem(ShellFilesystemPermissionRequest) } public var command: String public var kind: Kind } -public struct NetworkPermissionRequest { +public struct ShellNetworkPermissionRequest { public var url: String public var method: String } -public enum FilesystemPermissionOperation: String { +public enum ShellFilesystemPermissionOperation: String { case stat case listDirectory case readFile @@ -321,8 +321,8 @@ public enum FilesystemPermissionOperation: String { case glob } -public struct FilesystemPermissionRequest { - public var operation: FilesystemPermissionOperation +public struct ShellFilesystemPermissionRequest { + public var operation: ShellFilesystemPermissionOperation public var path: String? public var sourcePath: String? public var destinationPath: String? @@ -330,19 +330,19 @@ public struct FilesystemPermissionRequest { public var recursive: Bool } -public enum PermissionDecision { +public enum ShellPermissionDecision { case allow case allowForSession case deny(message: String?) } ``` -### `NetworkPolicy` +### `ShellNetworkPolicy` ```swift -public struct NetworkPolicy { - public static let disabled: NetworkPolicy - public static let unrestricted: NetworkPolicy +public struct ShellNetworkPolicy { + public static let disabled: ShellNetworkPolicy + public static let unrestricted: ShellNetworkPolicy public var allowsHTTPRequests: Bool public var allowedHosts: [String] @@ -357,14 +357,14 @@ Outbound HTTP(S) is disabled by default. Use `.unrestricted` or set `allowsHTTPR ```swift public struct SessionOptions { - public var filesystem: any ShellFilesystem + public var filesystem: any FileSystem public var layout: SessionLayout public var initialEnvironment: [String: String] public var enableGlobbing: Bool public var maxHistory: Int - public var networkPolicy: NetworkPolicy + public var networkPolicy: ShellNetworkPolicy public var executionLimits: ExecutionLimits - public var permissionHandler: (@Sendable (PermissionRequest) async -> PermissionDecision)? + public var permissionHandler: (@Sendable (ShellPermissionRequest) async -> ShellPermissionDecision)? public var secretPolicy: SecretHandlingPolicy public var secretResolver: (any SecretReferenceResolving)? public var secretOutputRedactor: any SecretOutputRedacting @@ -377,7 +377,7 @@ Defaults: - `initialEnvironment`: `[:]` - `enableGlobbing`: `true` - `maxHistory`: `1000` -- `networkPolicy`: `NetworkPolicy.disabled` +- `networkPolicy`: `ShellNetworkPolicy.disabled` - `executionLimits`: `ExecutionLimits.default` - `permissionHandler`: `nil` - `secretPolicy`: `.off` @@ -390,7 +390,7 @@ Example built-in policy plus callback: ```swift let options = SessionOptions( - networkPolicy: NetworkPolicy( + networkPolicy: ShellNetworkPolicy( allowsHTTPRequests: true, allowedHosts: ["api.example.com"], allowedURLPrefixes: ["https://api.example.com/v1/"], @@ -423,7 +423,7 @@ Available filesystem implementations: - `SandboxFilesystem`: resolves app container-style roots (`documents`, `caches`, `temporary`, app group, custom URL). - `SecurityScopedFilesystem`: URL/bookmark-backed filesystem for security-scoped access. -For non-shell agent tooling, `Workspace` exposes the same filesystem stack under shell-agnostic names like `WorkspaceFilesystem`, `WorkspacePath`, `WorkspaceError`, and `PermissionedWorkspaceFilesystem`, along with the higher-level `Workspace` actor for typed tree traversal and batch editing helpers. A single `Workspace` can also sit on top of a `MountableFilesystem`, so isolated roots plus a shared `/memory` mount are already possible through the current interfaces. +For non-shell agent tooling, `Workspace` exposes the same filesystem stack under shell-agnostic names like `FileSystem`, `WorkspacePath`, `WorkspaceError`, `PermissionRequest`, `PermissionDecision`, `PermissionAuthorizer`, and `PermissionedFileSystem`, along with the higher-level `Workspace` actor for typed tree traversal and batch editing helpers. A single `Workspace` can also sit on top of a `MountableFilesystem`, so isolated roots plus a shared `/memory` mount are already possible through the current interfaces. ### `SessionLayout` @@ -447,8 +447,8 @@ Execution pipeline: Current hardening layers include: - Root-jail filesystem implementations plus null-byte path rejection. -- Reusable workspace-level permission wrappers (`PermissionedWorkspaceFilesystem`) that can gate reads, writes, moves, copies, symlinks, and metadata operations before they hit the underlying filesystem. -- Optional `NetworkPolicy` rules with default-off HTTP(S), `denyPrivateRanges`, host allowlists, URL-prefix allowlists, and the host `permissionHandler`. +- Reusable workspace-level permission wrappers that can gate reads, writes, moves, copies, symlinks, and metadata operations before they hit the underlying filesystem. +- Optional `ShellNetworkPolicy` rules with default-off HTTP(S), `denyPrivateRanges`, host allowlists, URL-prefix allowlists, and the host `permissionHandler`. - Built-in execution budgets for command count, loop iterations, function depth, and command substitution depth, plus host-driven cancellation. - Strict `BashPython` shims that block process/FFI escape APIs like `subprocess`, `ctypes`, and `os.system`. - Secret-reference resolution/redaction policies that keep opaque references in model-visible flows by default. @@ -494,7 +494,7 @@ Behavior guarantees: - For `ReadWriteFilesystem`, symlink escapes outside root are blocked. - Filesystem implementations reject paths containing null bytes. - Built-in command stubs are created under `/bin` and `/usr/bin` inside the selected filesystem. -- Unsupported platform features are surfaced as runtime `ShellError.unsupported(...)`, while all current package targets still compile. +- Unsupported platform features are surfaced as runtime unsupported errors from either `Bash` or `Workspace`, while all current package targets still compile. Rootless session init example: @@ -505,9 +505,9 @@ let session = try await BashSession(options: inMemory) `BashSession.init(options:)` uses the filesystem exactly as provided. Pass a ready-to-use filesystem instance. `InMemoryFilesystem` works immediately; root-backed filesystems should be constructed or configured with their root before being passed in. -You can provide a custom filesystem by implementing `ShellFilesystem`. +You can provide a custom filesystem by implementing `FileSystem`. -If you do not need shell semantics, use `WorkspaceFilesystem` and the higher-level `Workspace` actor directly. The underlying jail, overlay, mount, bookmark, and permission concepts are shared; the shell layer is optional. +If you do not need shell semantics, use the `Workspace` package's filesystem APIs and the higher-level `Workspace` actor directly. The underlying jail, overlay, mount, bookmark, and permission concepts are shared; the shell layer is optional. ### Filesystem Platform Matrix @@ -518,7 +518,7 @@ If you do not need shell semantics, use `WorkspaceFilesystem` and the higher-lev | `OverlayFilesystem` | supported | supported | supported | supported | | `MountableFilesystem` | supported | supported | supported | supported | | `SandboxFilesystem` | supported (where root resolves) | supported (where root resolves) | supported (where root resolves) | supported (where root resolves) | -| `SecurityScopedFilesystem` | supported | supported | supported | compiles; throws `ShellError.unsupported` when configured | +| `SecurityScopedFilesystem` | supported | supported | supported | compiles; throws `WorkspaceError.unsupported(...)` when constructed on unsupported platforms | ### Security-Scoped Bookmark Flow diff --git a/docs/command-parity-gaps.md b/docs/command-parity-gaps.md index d64a89a..4d64313 100644 --- a/docs/command-parity-gaps.md +++ b/docs/command-parity-gaps.md @@ -7,5 +7,5 @@ This document tracks major command parity gaps relative to `just-bash` and shell | Job control (`&`, `$!`, `jobs`, `fg`, `wait`, `ps`, `kill`) | Background execution, pseudo-PID tracking, process listing, and signal-style termination are supported for in-process commands with buffered stdout/stderr handoff. | Medium | No stopped-job state transitions (`bg`, `disown`, `SIGTSTP`/`SIGCONT`) and no true host-process/TTY semantics. | `Tests/BashTests/ParserAndFilesystemTests.swift`, `Tests/BashTests/SessionIntegrationTests.swift` | | Shell language (`$(...)`, `for`, functions) | Command substitution, here-documents via `<<` / `<<-`, unquoted heredoc expansion for the shell’s supported `$VAR` / `${...}` / `$((...))` / `$(...)` features, `if/elif/else`, `while`, `until`, `case`, `for ... in ...`, C-style `for ((...))`, function keyword form (`function name {}`), `local` scoping, direct function positional params (`$1`, `$@`, `$#`), and richer `$((...))` arithmetic operators are supported. | Medium | Still not a full shell grammar (no `select`, no nested/compound parser parity, no backtick command substitution or full bash heredoc escape semantics, no full bash function/parameter-expansion surface, and no full arithmetic-assignment grammar). | `Tests/BashTests/ParserAndFilesystemTests.swift`, `Tests/BashTests/SessionIntegrationTests.swift` | | `head` / `tail` | Line-count shorthand forms such as `head -100`, `tail -100`, `tail +100`, and attached short-option values like `-n100` are supported alongside the standard `-n` form. | Low | Still lacks full GNU signed-count parity such as interpreting negative `head -n` counts as "all but the last N" or `tail -c +N` byte-from-start semantics. | `Tests/BashTests/SessionIntegrationTests.swift`, `Tests/BashTests/CommandCoverageTests.swift` | -| `curl` / `wget` | In-process `curl`/`wget` emulation supports `data:`, jailed `file:`, and opt-in HTTP(S) fetches, plus built-in `NetworkPolicy` controls (default-off HTTP(S), `denyPrivateRanges`, host allowlists, exact URL-prefix/path-boundary allowlists) and an optional host permission callback with per-session exact-request grants. | Medium | No recursive `wget` modes, robots handling, retry/progress parity, or full auth/flag compatibility. | `Tests/BashTests/SessionIntegrationTests.swift`, `Tests/BashTests/CommandCoverageTests.swift` | +| `curl` / `wget` | In-process `curl`/`wget` emulation supports `data:`, jailed `file:`, and opt-in HTTP(S) fetches, plus built-in `ShellNetworkPolicy` controls (default-off HTTP(S), `denyPrivateRanges`, host allowlists, exact URL-prefix/path-boundary allowlists) and an optional host permission callback with per-session exact-request grants. | Medium | No recursive `wget` modes, robots handling, retry/progress parity, or full auth/flag compatibility. | `Tests/BashTests/SessionIntegrationTests.swift`, `Tests/BashTests/CommandCoverageTests.swift` | | `python3` / `python` | Embedded CPython with strict shell-filesystem shims; supports `-c`, `-m`, script file/stdin execution, core stdlib + filesystem interoperability, and reuses the session network policy/permission path for socket connections. | Medium | Broader CLI flag parity, full stdlib/native-extension parity, packaging (`pip`) support, richer compatibility with process APIs (intentionally blocked in strict mode), and deeper coverage for higher-level networking libraries. | `Tests/BashPythonTests/Python3CommandTests.swift`, `Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift` | From 487f36a46d2be5fd435908775bda7dbc3693cca7 Mon Sep 17 00:00:00 2001 From: Zac White Date: Mon, 23 Mar 2026 12:39:00 -0700 Subject: [PATCH 13/14] Updated README --- README.md | 709 ++++++++++-------------------------------------------- 1 file changed, 127 insertions(+), 582 deletions(-) diff --git a/README.md b/README.md index d688a3b..ef6dd0a 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,24 @@ # Bash.swift -`Bash.swift` provides an in-process, stateful, emulated shell for Swift apps. It's heavily inspired by [just-bash](https://github.com/vercel-labs/just-bash). +`Bash.swift` is an in-process, stateful shell for Swift apps. It is inspired by [just-bash](https://github.com/vercel-labs/just-bash). Commands runs inside Swift instead of spawning host shell processes. -Repository: [github.com/velos/Bash.swift](https://github.com/velos/Bash.swift) +You create a `BashSession`, run shell command strings, and get structured `stdout`, `stderr`, and `exitCode` results back. Session state persists across runs, including the working directory, environment, history, and registered built-ins. -You create a `BashSession`, run shell command strings, and get structured `stdout` / `stderr` / `exitCode` results. Commands mutate a real directory on disk through a sandboxed, root-jail filesystem abstraction. - -Like `just-bash`, `Bash.swift` should be treated as beta software and used at your own risk. The library is practical for app and agent workflows, but it is still evolving and should not be treated as a hardened isolation boundary or a drop-in replacement for a real system shell. - -## Development Process - -Development of `Bash.swift` was approached very similarly to [just-bash](https://github.com/vercel-labs/just-bash). All output was with GPT-5.3-Codex Extra High thinking, initiated by an interactively built plan, executed by the model after the plan was finalized. - -## Contents - -- [Why](#why) -- [Installation](#installation) -- [Platform Support](#platform-support) -- [Quick Start](#quick-start) -- [Workspace Modules](#workspace-modules) -- [Public API](#public-api) -- [How It Works](#how-it-works) -- [Security](#security) -- [Filesystem Model](#filesystem-model) -- [Implemented Commands](#implemented-commands) -- [Eval Runner and Profiles](#eval-runner-and-profiles) -- [Testing](#testing) -- [Roadmap](#roadmap) +`Bash.swift` should be treated as beta software. It is practical for app and agent workflows, but it is not a hardened isolation boundary and it is not a drop-in replacement for a real system shell. APIs are being actively experimented with and deployed. Ensure you lock to a specific commit or version tag if you plan to do any work utilizing this library. ## Why -`Bash.swift` is aimed at providing a tool for use in agents. Leveraging the approach that "Bash is all you need". To enable this use-case, it provides: -- Stateful shell session (`cd`, `export`, `history` persist across `run` calls) -- Real filesystem side effects under a controlled root directory -- Built-in fake CLIs implemented in Swift (no subprocess dependency) -- Shell parsing/execution features needed for scripts (`|`, redirection, `&&`, `||`, `;`, `&`) +`Bash.swift` is built for app and agent workflows that need shell-like behavior without subprocess management. + +It provides: +- Stateful shell sessions (`cd`, `export`, `history`, shell functions) +- Real filesystem side effects under a controlled root +- In-process built-in commands implemented in Swift +- Practical shell syntax support for pipelines, redirection, chaining, background jobs, and simple scripting ## Installation -### Swift Package Manager (remote package) +Add `Bash` with SwiftPM: ```swift // Package.swift @@ -53,22 +33,19 @@ Development of `Bash.swift` was approached very similarly to [just-bash](https:/ ] ``` -The reusable shell-agnostic workspace layer now lives in a separate `Workspace` package/repository. `Bash.swift` depends on that package, but it is no longer shipped as part of this repo. - -`BashSQLite`, `BashPython`, `BashGit`, and `BashSecrets` are optional products. Add them only if needed: +Optional products: ```swift dependencies: ["Bash", "BashSQLite", "BashPython", "BashGit", "BashSecrets"] ``` -`BashPython` uses a remote `CPython.xcframework` binary target hosted in the repo's GitHub Releases, so consumers do not -need Git LFS and the prebuilt CPython framework is not checked into the repository. - -If you include optional products, remember to register their commands at runtime (`registerSQLite3`, `registerPython`, `registerGit`, `registerSecrets`). +Notes: +- `Bash.swift` now depends on a separate `Workspace` package for the reusable filesystem layer. +- `Bash` reexports the Workspace filesystem types, so callers can use `FileSystem`, `WorkspacePath`, `ReadWriteFilesystem`, `InMemoryFilesystem`, `OverlayFilesystem`, `MountableFilesystem`, `SandboxFilesystem`, and `SecurityScopedFilesystem` directly from `Bash`. +- `BashPython` uses a prebuilt `CPython.xcframework` binary target. +- `BashGit` uses a prebuilt `Clibgit2.xcframework` binary target. -## Platform Support - -Current package platforms: +Supported package platforms: - macOS 13+ - iOS 16+ - tvOS 16+ @@ -91,7 +68,7 @@ let piped = await session.run("echo hello | tee out.txt > copy.txt") print(piped.exitCode) // 0 ``` -For isolated one-off overrides without mutating the session's persisted `cwd` or environment: +For isolated per-run overrides without mutating the session's persisted shell state: ```swift let scoped = await session.run( @@ -101,108 +78,64 @@ let scoped = await session.run( currentDirectory: "/tmp" ) ) -print(scoped.stdoutString) ``` -Optional `sqlite3` registration: +## Optional Modules + +Optional command sets must be registered at runtime. + +`BashSQLite`: ```swift import BashSQLite await session.registerSQLite3() -let sql = await session.run("sqlite3 :memory: \"select 1;\"") -print(sql.stdoutString) // 1 +let result = await session.run("sqlite3 :memory: \"select 1;\"") +print(result.stdoutString) // 1 ``` -Optional `python3` / `python` registration: +`BashPython`: ```swift import BashPython -await BashPython.setCPythonRuntime() // Optional: defaults to strict filesystem shims. +await BashPython.setCPythonRuntime() await session.registerPython() - let py = await session.run("python3 -c \"print('hi')\"") print(py.stdoutString) // hi ``` -`BashPython` embeds CPython directly (no JavaScriptCore/Pyodide path). The current prebuilt CPython runtime is available on macOS. -On other Apple platforms, including iOS/iPadOS, Mac Catalyst, tvOS, and watchOS, the module still compiles but runtime execution returns an unavailable error. -Maintainer notes for the broader Apple runtime plan live in [`docs/cpython-apple-runtime.md`](docs/cpython-apple-runtime.md). +`BashPython` embeds CPython directly. The current prebuilt runtime is available on macOS. Other Apple platforms still compile, but runtime execution returns unavailable errors. Filesystem access stays inside the shell's configured `FileSystem`, and escape APIs such as `subprocess`, `ctypes`, and `os.system` are intentionally blocked. Maintainer notes for the broader Apple runtime plan live in [docs/cpython-apple-runtime.md](docs/cpython-apple-runtime.md). -Strict filesystem mode is enabled by default. Script-visible file APIs are shimmed through the reexported `FileSystem` layer, so Python file operations share the same jailed root as shell commands. -Blocked escape APIs include `subprocess`, `ctypes`, and process-spawn helpers like `os.system` / `os.popen` / `os.spawn*`. -`SessionOptions.networkPolicy` and `permissionHandler` also apply to Python socket connections, so host apps can enforce the same outbound rules across shell commands and embedded Python. -`pip` and arbitrary native extension loading are non-goals in this runtime profile. - -Optional `git` registration: +`BashGit`: ```swift import BashGit await session.registerGit() _ = await session.run("git init") -_ = await session.run("git add -A") -let commit = await session.run("git commit -m \"Initial commit\"") -print(commit.exitCode) ``` -`BashGit` uses a prebuilt `Clibgit2.xcframework` binary target (iOS, iOS Simulator, macOS, Catalyst). The binary artifact is fetched by SwiftPM during dependency resolution. - -Optional `secrets` registration: +`BashSecrets`: ```swift import BashSecrets let provider = AppleKeychainSecretsProvider() await session.registerSecrets(provider: provider) -let ref = await session.run("secrets put --service app --account api", stdin: Data("token".utf8)) -let use = await session.run("secrets run --env API_TOKEN=\(ref.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)) -- printenv API_TOKEN") -print(use.stdoutString) -``` - -`BashSecrets` uses provider-owned opaque `secretref:...` references. `AppleKeychainSecretsProvider` stores secrets in Apple Keychain generic-password entries and keeps the reference-sealing key in a reserved internal Keychain item so refs remain durable for that provider/backend. - -For harness/tooling flows where the model should only handle references, use the provider API directly: - -```swift -let provider = AppleKeychainSecretsProvider() -let ref = try await provider.putGenericPassword( - service: "app", - account: "api", - value: Data("token".utf8) -) - -// Resolve inside trusted tool code, not in model-visible shell output. -let secretValue = try await provider.resolveReference(ref) -``` - -For secret-aware command execution/redaction inside `BashSession`, wire the same provider into the session: - -```swift -let session = try await BashSession( - rootDirectory: root, - options: SessionOptions( - filesystem: ReadWriteFilesystem(), - layout: .unixLike - ) +let ref = await session.run( + "secrets put --service app --account api", + stdin: Data("token".utf8) ) -let provider = AppleKeychainSecretsProvider() -await session.registerSecrets(provider: provider, policy: .strict) ``` -Policies: -- `.off`: no automatic secret-reference resolution/redaction in builtins -- `.resolveAndRedact`: resolve refs only in explicit sinks and redact caller-visible `stdout`/`stderr` -- `.strict`: like `.resolveAndRedact`, plus blocks high-risk flows like `secrets get --reveal` +`BashSecrets` uses provider-owned opaque `secretref:...` references. `secrets get --reveal` is explicit, and `.resolveAndRedact` or `.strict` policies keep plaintext out of caller-visible output by default. -## Workspace Modules +## Workspace Package -`Bash` now sits on top of reusable workspace primitives provided by a separate `Workspace` package: +`Bash` sits on top of a reusable `Workspace` package. If you only need filesystem and workspace tooling, use `Workspace` directly instead of `BashSession`. -- `Workspace`: a typed agent-facing API plus the shell-agnostic filesystem abstractions, jailed/rooted filesystem implementations, overlays, mounts, bookmarks, path helpers, and filesystem permission wrappers it is built on. - -Import `Workspace` from that package directly when you want workspace tooling without shell parsing or command execution: +Example: ```swift import Workspace @@ -223,11 +156,9 @@ let workspace = Workspace(filesystem: filesystem) let tree = try await workspace.summarizeTree("/workspace", maxDepth: 2) ``` -`replaceInFiles` and `applyEdits` support dry runs plus best-effort rollback on failure. That rollback is logical state restoration within the provided filesystem, not an OS-level atomic transaction and not crash-safe across processes. - -## Public API +## API Summary -### `BashSession` +Primary entry point: ```swift public final actor BashSession { @@ -236,502 +167,116 @@ public final actor BashSession { public func run(_ commandLine: String, stdin: Data = Data()) async -> CommandResult public func run(_ commandLine: String, options: RunOptions) async -> CommandResult public func register(_ command: any BuiltinCommand.Type) async - public var currentDirectory: String { get async } - public var environment: [String: String] { get async } } ``` -### `CommandResult` +High-level types: +- `CommandResult`: `stdout`, `stderr`, `exitCode`, plus string helpers +- `RunOptions`: per-run `stdin`, environment overrides, temporary `cwd`, execution limits, and cancellation probe +- `ExecutionLimits`: caps command count, function depth, loop iterations, command substitution depth, and optional wall-clock duration +- `SessionOptions`: filesystem, layout, initial environment, globbing, history length, network policy, execution limits, permission callback, and secret policy +- `ShellPermissionRequest` / `ShellPermissionDecision`: shell-facing permission callback types +- `ShellNetworkPolicy`: built-in outbound network policy -```swift -public struct CommandResult { - public var stdout: Data - public var stderr: Data - public var exitCode: Int32 +Practical behavior: +- `BashSession.init` can throw during setup +- `run` always returns a `CommandResult`, including parser/runtime faults +- Unknown commands return exit code `127` +- Parser/runtime faults use exit code `2` +- `maxWallClockDuration` failures use exit code `124` +- Cancellation uses exit code `130` - public var stdoutString: String { get } - public var stderrString: String { get } -} -``` - -### `RunOptions` - -```swift -public struct RunOptions { - public var stdin: Data - public var environment: [String: String] - public var replaceEnvironment: Bool - public var currentDirectory: String? - public var executionLimits: ExecutionLimits? - public var cancellationCheck: (@Sendable () -> Bool)? -} -``` - -Use `RunOptions` when you want a Cloudflare-style per-execution override without changing the session's persisted shell state. Filesystem mutations still persist; environment, working-directory, and function changes from that run do not. You can also tighten execution budgets or provide a host cancellation probe for a single run. - -### `ExecutionLimits` +## Security Model -```swift -public struct ExecutionLimits { - public static let `default`: ExecutionLimits - - public var maxCommandCount: Int - public var maxFunctionDepth: Int - public var maxLoopIterations: Int - public var maxCommandSubstitutionDepth: Int - public var maxWallClockDuration: TimeInterval? -} -``` - -Each `run` executes under an `ExecutionLimits` budget. Exceeding a structural limit stops execution with exit code `2`. If `maxWallClockDuration` is exceeded, execution stops with exit code `124`. If `cancellationCheck` returns `true`, or the surrounding task is cancelled, execution stops with exit code `130`. Wall-clock accounting excludes time spent waiting on host permission callbacks. - -### `ShellPermissionRequest` and `ShellPermissionDecision` - -```swift -public struct ShellPermissionRequest { - public enum Kind { - case network(ShellNetworkPermissionRequest) - case filesystem(ShellFilesystemPermissionRequest) - } - - public var command: String - public var kind: Kind -} - -public struct ShellNetworkPermissionRequest { - public var url: String - public var method: String -} - -public enum ShellFilesystemPermissionOperation: String { - case stat - case listDirectory - case readFile - case writeFile - case createDirectory - case remove - case move - case copy - case createSymlink - case createHardLink - case readSymlink - case setPermissions - case resolveRealPath - case exists - case glob -} - -public struct ShellFilesystemPermissionRequest { - public var operation: ShellFilesystemPermissionOperation - public var path: String? - public var sourcePath: String? - public var destinationPath: String? - public var append: Bool - public var recursive: Bool -} - -public enum ShellPermissionDecision { - case allow - case allowForSession - case deny(message: String?) -} -``` - -### `ShellNetworkPolicy` - -```swift -public struct ShellNetworkPolicy { - public static let disabled: ShellNetworkPolicy - public static let unrestricted: ShellNetworkPolicy - - public var allowsHTTPRequests: Bool - public var allowedHosts: [String] - public var allowedURLPrefixes: [String] - public var denyPrivateRanges: Bool -} -``` - -Outbound HTTP(S) is disabled by default. Use `.unrestricted` or set `allowsHTTPRequests: true` to opt in. `allowedHosts` fits host-level allowlisting that should also apply to `git` remotes and Python socket connections. `allowedURLPrefixes` is stricter and is matched with exact scheme/host/port plus path-boundary validation for URL-aware tools like `curl` and `wget`. When an allowlist is present, a request must match the host list or the URL-prefix list before any private-range DNS checks run. - -### `SessionOptions` - -```swift -public struct SessionOptions { - public var filesystem: any FileSystem - public var layout: SessionLayout - public var initialEnvironment: [String: String] - public var enableGlobbing: Bool - public var maxHistory: Int - public var networkPolicy: ShellNetworkPolicy - public var executionLimits: ExecutionLimits - public var permissionHandler: (@Sendable (ShellPermissionRequest) async -> ShellPermissionDecision)? - public var secretPolicy: SecretHandlingPolicy - public var secretResolver: (any SecretReferenceResolving)? - public var secretOutputRedactor: any SecretOutputRedacting -} -``` - -Defaults: -- `filesystem`: `ReadWriteFilesystem()` -- `layout`: `.unixLike` -- `initialEnvironment`: `[:]` -- `enableGlobbing`: `true` -- `maxHistory`: `1000` -- `networkPolicy`: `ShellNetworkPolicy.disabled` -- `executionLimits`: `ExecutionLimits.default` -- `permissionHandler`: `nil` -- `secretPolicy`: `.off` -- `secretResolver`: `nil` -- `secretOutputRedactor`: `DefaultSecretOutputRedactor()` - -Use `networkPolicy` for built-in outbound rules such as default-off HTTP(S), private-range blocking, and allowlists. Use `executionLimits` to bound shell work at the session level. Use `permissionHandler` when the host app or agent needs explicit control over filesystem and outbound network access after the built-in policy passes. Returning `.allow` grants the current request once, `.allowForSession` caches an exact-match request for the life of that `BashSession`, and `.deny(message:)` blocks it with a user-visible error. If you want broader or persistent memory across sessions, keep that policy in the host and decide what to return from the callback. - -Example built-in policy plus callback: - -```swift -let options = SessionOptions( - networkPolicy: ShellNetworkPolicy( - allowsHTTPRequests: true, - allowedHosts: ["api.example.com"], - allowedURLPrefixes: ["https://api.example.com/v1/"], - denyPrivateRanges: true - ), - permissionHandler: { request in - switch request.kind { - case let .network(network): - if network.url.hasPrefix("https://api.example.com/v1/") { - return .allowForSession - } - return .deny(message: "network access denied") - case let .filesystem(filesystem): - switch filesystem.operation { - case .readFile, .listDirectory, .stat: - return .allowForSession - default: - return .deny(message: "filesystem access denied") - } - } - } -) -``` - -Available filesystem implementations: -- `ReadWriteFilesystem`: root-jail wrapper over real disk I/O. -- `InMemoryFilesystem`: fully in-memory filesystem with no disk writes. -- `OverlayFilesystem`: snapshots an on-disk root into an in-memory overlay for the session; later writes stay in memory. -- `MountableFilesystem`: composes multiple filesystems under virtual mount points like `/workspace` and `/docs`. -- `SandboxFilesystem`: resolves app container-style roots (`documents`, `caches`, `temporary`, app group, custom URL). -- `SecurityScopedFilesystem`: URL/bookmark-backed filesystem for security-scoped access. - -For non-shell agent tooling, `Workspace` exposes the same filesystem stack under shell-agnostic names like `FileSystem`, `WorkspacePath`, `WorkspaceError`, `PermissionRequest`, `PermissionDecision`, `PermissionAuthorizer`, and `PermissionedFileSystem`, along with the higher-level `Workspace` actor for typed tree traversal and batch editing helpers. A single `Workspace` can also sit on top of a `MountableFilesystem`, so isolated roots plus a shared `/memory` mount are already possible through the current interfaces. - -### `SessionLayout` - -- `.unixLike` (default): creates `/home/user`, `/bin`, `/usr/bin`, `/tmp`; starts in `/home/user` -- `.rootOnly`: minimal root-only layout - -## How It Works - -Execution pipeline: -1. Command line is lexed and parsed into a shell AST. -2. Variables/globs are expanded. -3. Pipelines/chains execute against registered in-process built-ins. -4. The session state is updated (`cwd`, environment, history). -5. `CommandResult` is returned. - -`run(_:options:)` follows the same pipeline, but starts from temporary environment / cwd overrides and restores the session shell state afterward. - -## Security - -`Bash.swift` is a practical execution environment, not a hardened security sandbox. The project is designed to keep command execution in-process, jail filesystem access to the configured root, and give the embedding app explicit control over sensitive surfaces such as secrets and outbound network access. That said, it should be treated as defense-in-depth for app and agent workflows, not as a guarantee that hostile code is safely contained. +`Bash.swift` is a practical execution environment, not a hardened sandbox. Current hardening layers include: -- Root-jail filesystem implementations plus null-byte path rejection. -- Reusable workspace-level permission wrappers that can gate reads, writes, moves, copies, symlinks, and metadata operations before they hit the underlying filesystem. -- Optional `ShellNetworkPolicy` rules with default-off HTTP(S), `denyPrivateRanges`, host allowlists, URL-prefix allowlists, and the host `permissionHandler`. -- Built-in execution budgets for command count, loop iterations, function depth, and command substitution depth, plus host-driven cancellation. -- Strict `BashPython` shims that block process/FFI escape APIs like `subprocess`, `ctypes`, and `os.system`. -- Secret-reference resolution/redaction policies that keep opaque references in model-visible flows by default. - -Security-sensitive embeddings should still assume the host app owns the real trust boundary. If you need durable user consent, domain reputation checks, persistent policy memory, stricter runtime isolation, or stronger resource limits, keep those controls in the host and use `BashSession` as one layer rather than the whole boundary. - -### Supported Shell Features - -- Quoting and escaping (`'...'`, `"..."`, `\\`) -- Pipes: `cmd1 | cmd2` -- Redirections: `>`, `>>`, `<`, `<<`, `<<-`, `2>`, `2>&1` -- Command chaining: `&&`, `||`, `;` -- Background execution: `&` with `jobs`, `fg`, `wait` -- Command substitution: `$(...)` (including nested forms) -- Simple `for` loops: `for name in values; do ...; done` (supports trailing redirections) -- Simple control flow: `if ... then ... else ... fi`, `while ...; do ...; done` -- Shell functions: `name(){ ...; }` definitions and invocation (persist across `run` calls) -- Variables: `$VAR`, `${VAR}`, `${VAR:-default}`, `$!` (last background pseudo-PID) -- Globs: `*`, `?`, `[abc]` (when `enableGlobbing` is true) -- Command lookup by name and by path-like invocation (`/bin/ls`) - -### Not Yet Supported (Shell Language) - -- Full positional-parameter semantics (`$0`, `$*`, quoted `$@` parity edge-cases) -- `if/then/elif/else/fi` advanced forms (`elif`, nested branches parity) -- `until` -- Full `for` loop surface (`for ...; do` newline form, omitted `in` list, C-style `for ((...))`) -- Function features like `local`, `return`, and `function name { ... }` syntax -- Full POSIX job-control signals/states (`bg`, `disown`, signal forwarding) +- Root-jail filesystem implementations plus null-byte path rejection +- Optional permission callbacks for filesystem and network access +- `ShellNetworkPolicy` with default-off HTTP(S), host allowlists, URL-prefix allowlists, and private-range blocking +- Execution budgets through `ExecutionLimits` +- Strict `BashPython` shims that block process and FFI escape APIs +- Secret-reference resolution and redaction policies + +Important notes: +- Outbound HTTP(S) is disabled by default +- `permissionHandler` applies after the built-in network policy passes +- Permission wait time is excluded from `timeout` and run-level wall-clock accounting +- `curl` / `wget`, `git clone`, and `BashPython` socket connections share the same network policy path +- `data:` URLs and jailed `file:` URLs do not trigger outbound network checks ## Filesystem Model -Built-in filesystem options: -- `ReadWriteFilesystem` (default): rooted at your `rootDirectory`; reads/writes hit disk in that sandboxed root. -- `InMemoryFilesystem`: virtual tree stored in memory; no file mutations are written to disk. -- `OverlayFilesystem`: imports an on-disk root into memory at session start; later writes stay in memory and do not modify the host root. -- `MountableFilesystem`: routes different virtual path prefixes to different filesystem backends. -- `SandboxFilesystem`: root resolved from container locations, then backed by `ReadWriteFilesystem`. -- `SecurityScopedFilesystem`: root resolved from security-scoped URL or bookmark, then backed by `ReadWriteFilesystem`. +Filesystems available via [Workspace](https://github.com/velos/Workspace): +- `ReadWriteFilesystem`: rooted real disk I/O +- `InMemoryFilesystem`: fully in-memory tree +- `OverlayFilesystem`: snapshots an on-disk root into memory; later writes stay in memory +- `MountableFilesystem`: composes multiple filesystems under virtual mount points +- `SandboxFilesystem`: container-root chooser (`documents`, `caches`, `temporary`, app group, custom URL) +- `SecurityScopedFilesystem`: security-scoped URL or bookmark-backed root Behavior guarantees: -- All operations are scoped under the filesystem root. -- For `ReadWriteFilesystem`, symlink escapes outside root are blocked. -- Filesystem implementations reject paths containing null bytes. -- Built-in command stubs are created under `/bin` and `/usr/bin` inside the selected filesystem. -- Unsupported platform features are surfaced as runtime unsupported errors from either `Bash` or `Workspace`, while all current package targets still compile. - -Rootless session init example: - -```swift -let inMemory = SessionOptions(filesystem: InMemoryFilesystem()) -let session = try await BashSession(options: inMemory) -``` - -`BashSession.init(options:)` uses the filesystem exactly as provided. Pass a ready-to-use filesystem instance. `InMemoryFilesystem` works immediately; root-backed filesystems should be constructed or configured with their root before being passed in. - -You can provide a custom filesystem by implementing `FileSystem`. +- All shell-visible paths are scoped to the configured filesystem root +- `ReadWriteFilesystem` blocks symlink escapes outside the root +- Filesystem implementations reject paths containing null bytes +- Built-in command stubs are created under `/bin` and `/usr/bin` for unix-like layouts +- Unsupported platform features surface as runtime unsupported errors from `Bash` or `Workspace` -If you do not need shell semantics, use the `Workspace` package's filesystem APIs and the higher-level `Workspace` actor directly. The underlying jail, overlay, mount, bookmark, and permission concepts are shared; the shell layer is optional. - -### Filesystem Platform Matrix - -| Filesystem | macOS | iOS | Catalyst | tvOS/watchOS | -| --- | --- | --- | --- | --- | -| `ReadWriteFilesystem` | supported | supported | supported | supported | -| `InMemoryFilesystem` | supported | supported | supported | supported | -| `OverlayFilesystem` | supported | supported | supported | supported | -| `MountableFilesystem` | supported | supported | supported | supported | -| `SandboxFilesystem` | supported (where root resolves) | supported (where root resolves) | supported (where root resolves) | supported (where root resolves) | -| `SecurityScopedFilesystem` | supported | supported | supported | compiles; throws `WorkspaceError.unsupported(...)` when constructed on unsupported platforms | - -### Security-Scoped Bookmark Flow +Rootless session example: ```swift -let store = UserDefaultsBookmarkStore() - -// Create from a URL chosen by your app's document flow. -let fs = try SecurityScopedFilesystem(url: pickedURL, mode: .readWrite) -try await fs.saveBookmark(id: "workspace", store: store) - -// Restore on a later app launch. -let restored = try await SecurityScopedFilesystem.loadBookmark( - id: "workspace", - store: store, - mode: .readWrite -) - -let session = try await BashSession( - options: SessionOptions(filesystem: restored, layout: .rootOnly) -) -``` - -## Implemented Commands - -All implemented commands support `--help`. - -### File Operations - -| Command | Supported Options | -| --- | --- | -| `cat` | positional files | -| `cp` | `-R`, `--recursive` | -| `ln` | `-s`, `--symbolic` | -| `ls` | `-l`, `-a` | -| `mkdir` | `-p` | -| `mv` | positional source/destination | -| `readlink` | positional path | -| `rm` | `-r`, `-R`, `-f` | -| `rmdir` | positional paths | -| `stat` | positional paths | -| `touch` | positional paths | -| `chmod` | ` `, `-R`, `--recursive` (octal mode only) | -| `file` | positional paths | -| `tree` | optional path, `-a`, `-L ` | -| `diff` | ` ` | - -### Text Processing - -| Command | Supported Options | -| --- | --- | -| `grep` | `-E`, `-F`, `-i`, `-v`, `-n`, `-c`, `-l`, `-L`, `-o`, `-w`, `-x`, `-r`, `-e `, `-f ` (`egrep`, `fgrep` aliases) | -| `rg` | `-i`, `-S`, `-F`, `-n`, `-l`, `-c`, `-m `, `-w`, `-x`, `-A/-B/-C`, `--hidden`, `--no-ignore`, `--files`, `-e `, `-f `, `-g/--glob`, `-t `, `-T ` | -| `head` | `-n`, `--n`, `-c`, `-q`, `-v` | -| `tail` | `-n`, `--n` (supports `+N`), `-c`, `-q`, `-v` | -| `wc` | `-l`, `-w`, `-c`, `-m`, `--chars` | -| `sort` | `-r`, `-n`, `-u`, `-f`, `-c`, `-k `, `-o ` | -| `uniq` | `-c`, `-d`, `-u`, `-i`; optional `[input [output]]` operands | -| `cut` | `-d `, `-f `, `-c `, `-s` (`list`: `N`, `N-M`, `-M`, `N-`) | -| `tr` | `-d`, `-s`, `-c`; supports escapes (`\\n`, `\\t`, `\\r`) and ranges (`a-z`) | -| `awk` | `-F `; supports `{print}`, `{print $N}`, `/regex/ {print ...}` | -| `sed` | substitution scripts only: `s/pattern/replacement/` and `s/.../.../g` | -| `xargs` | `-I `, `-d `, `-n `, `-L/--max-lines `, `-E/--eof `, `-P `, `-0/--null`, `-t/--verbose`, `-r/--no-run-if-empty`; default command `echo` | -| `printf` | format string + positional values (`%s`, `%d`, `%i`, `%f`, `%%`) | -| `base64` | encode by default; `-d`, `--decode` | -| `sha256sum` | optional files (or stdin) | -| `sha1sum` | optional files (or stdin) | -| `md5sum` | optional files (or stdin) | - -### Data Processing - -| Command | Supported Options | -| --- | --- | -| `sqlite3` | **Opt-in via `BashSQLite`**: modes `-list`, `-csv`, `-json`, `-line`, `-column`, `-table`, `-markdown`; `-header`, `-noheader`, `-separator `, `-newline `, `-nullvalue `, `-readonly`, `-bail`, `-cmd `, `-version`, `--`; syntax `sqlite3 [options] [database] [sql]` | -| `python3` / `python` | **Opt-in via `BashPython`**: embedded CPython runtime (`python3 [OPTIONS] [-c CODE | -m MODULE | FILE] [ARGS...]`); supports `-c`, `-m`, `-V/--version`, stdin execution, and script/module execution against strict shell-filesystem shims (process/FFI escape APIs blocked) | -| `secrets` / `secret` | **Opt-in via `BashSecrets`**: `put`, `get`, `delete`, `run`; provider-backed reference-first flows (`secretref:...`) and explicit `get --reveal` for plaintext output | -| `jq` | `-r`, `-c`, `-e`, `-s`, `-n`, `-j`, `-S`; query + optional files. Query subset supports paths, `|`, `select(...)`, comparisons, `and`/`or`/`not`, `//` | -| `yq` | `-r`, `-c`, `-e`, `-s`, `-n`, `-j`, `-S`; query + optional files (YAML + JSON input), same query subset as `jq` | -| `xan` | subcommands: `count`, `headers`, `select`, `filter` | - -### Compression & Archives - -| Command | Supported Options | -| --- | --- | -| `gzip` | `-d`, `--decompress`, `-c`, `-k`, `-f` | -| `gunzip` | `-c`, `-k`, `-f` | -| `zcat` | positional files (or stdin) | -| `zip` | `-r`, `-0`, `--store`; `zip ` | -| `unzip` | `-l`, `-p`, `-o`, `-d ` | -| `tar` | `-c`, `-x`, `-t`, `-z`, `-f `, `-C ` | - -### Navigation & Environment - -| Command | Supported Options | -| --- | --- | -| `basename` | positional names; `-a`, `-s ` | -| `cd` | optional positional path | -| `dirname` | positional paths | -| `du` | `-s` | -| `echo` | `-n` | -| `env` | none | -| `export` | positional `KEY=VALUE` assignments | -| `find` | paths + expression subset: `-name/-iname`, `-path/-ipath`, `-regex/-iregex`, `-type`, `-mtime`, `-size`, `-perm`, `-maxdepth/-mindepth`, `-a/-o/!` with grouping `(...)`, `-prune`, `-print/-print0/-printf`, `-delete`, `-exec ... \\;` / `-exec ... +` | -| `hostname` | none | -| `printenv` | optional positional keys (non-zero if any key is missing) | -| `pwd` | none | -| `tee` | `-a` | - -### Shell Utilities - -| Command | Supported Options | -| --- | --- | -| `clear` | none | -| `date` | `-u` | -| `false` | none | -| `fg` | optional job spec (`fg`, `fg %1`) | -| `help` | optional command name (`help `) | -| `history` | `-n`, `--n` | -| `jobs` | none | -| `kill` | `kill [-s SIGNAL | -SIGNAL] ...`, `kill -l` | -| `ps` | `ps`, `ps -p `, compatibility flags `-e`, `-f`, `-a`, `-x`, `aux` | -| `seq` | `-s `, `-w`, positional numeric args | -| `sleep` | positional durations (`NUMBER[SUFFIX]`, suffix: `s`, `m`, `h`, `d`) | -| `time` | `time ` | -| `timeout` | `timeout `; uses effective elapsed time and excludes host permission callback waits | -| `true` | none | -| `wait` | optional job specs (`wait`, `wait %1`) | -| `whoami` | none | -| `which` | `-a`, `-s`, positional command names | - -### Network Commands - -| Command | Supported Options | -| --- | --- | -| `curl` | URL argument; `-s`, `-S`, `-i`, `-I`, `-f`, `-L`, `-v`, `-X `, `-H
...`, `-A `, `-e `, `-u `, `-b `, `-c `, `-d/--data ...`, `--data-raw ...`, `--data-binary ...`, `--data-urlencode ...`, `-T `, `-F `, `-o `, `-O`, `-w `, `-m `, `--connect-timeout `, `--max-redirs `; supports `data:`, `file:`, and HTTP(S) URLs (`file:` is scoped to the shell filesystem root) | -| `wget` | URL argument; `--version`, `-q/--quiet`, `-O/--output-document `, `--user-agent ` | -| `html-to-markdown` | `-b/--bullet `, `-c/--code `, `-r/--hr `, `--heading-style `; input from file or stdin; strips `script/style/footer` blocks; supports nested lists and Markdown table rendering | - -When the active secret policy is `.resolveAndRedact` or `.strict`, `curl` resolves `secretref:...` tokens in headers/body arguments and caller-visible output redaction replaces resolved values with their reference tokens. Normal shell file redirections keep plaintext bytes. -When `SessionOptions.networkPolicy` is set, `curl`/`wget`, `git clone` remotes, and `BashPython` socket connections enforce the same built-in default-off HTTP(S), allowlist, and private-range rules. -When `SessionOptions.permissionHandler` is set, shell filesystem operations and redirections ask it before reading or mutating files, `curl` and `wget` ask it before outbound HTTP(S) requests, `git clone` asks it before remote clones, and `BashPython` asks it before socket connections. Permission callback wait time is excluded from both `timeout` and run-level wall-clock budgets. `data:` and jailed `file:` URLs do not trigger network checks. - -## Command Behaviors and Notes - -- Unknown commands return exit code `127` and write `command not found` to `stderr`. -- Non-zero command exits are returned in `CommandResult.exitCode` (not thrown). -- `BashSession.init` can throw; `run` always returns `CommandResult` (including parser/runtime failures with exit code `2`). -- Pipelines are currently sequential and buffered (`stdout` from one command becomes `stdin` for the next command). - -## Eval Runner and Profiles - -`BashEvalRunner` executes NL shell tasks from YAML task banks and validates results with deterministic shell checks. -Use it to compare Bash.swift against system bash and track parser/command parity over time. - -Primary eval docs live in `docs/evals/README.md`. - -Profiles: -- `docs/evals/general/profile.yaml`: broad command and workflow cross-section with `core` and `gap-probe` tiers. -- `docs/evals/language-deep/profile.yaml`: shell-language stress profile for command substitution, `for` loops, functions, redirection edges, and control-flow probes. - -Build runner: - -```bash -swift build --target BashEvalRunner +let options = SessionOptions(filesystem: InMemoryFilesystem(), layout: .unixLike) +let session = try await BashSession(options: options) ``` -Run `general` with static command plans: +## Shell Scope -```bash -swift run BashEvalRunner \ - --profile docs/evals/general/profile.yaml \ - --engine bashswift \ - --commands-file docs/evals/examples/commands.json \ - --report /tmp/bash-eval-report.json -``` - -Run `language-deep` with static command plans: - -```bash -swift run BashEvalRunner \ - --profile docs/evals/language-deep/profile.yaml \ - --engine bashswift \ - --commands-file docs/evals/language-deep/commands.json \ - --report /tmp/bash-language-deep-report.json -``` - -Run with an external planner command: - -```bash -swift run BashEvalRunner \ - --profile docs/evals/general/profile.yaml \ - --engine bashswift \ - --agent-command './scripts/plan_commands.sh' \ - --report /tmp/bash-eval-report.json -``` +Supported shell features include: +- Quoting and escaping +- Pipes +- Redirections: `>`, `>>`, `<`, `<<`, `<<-`, `2>`, `2>&1` +- Chaining: `&&`, `||`, `;` +- Background execution with `jobs`, `fg`, `wait`, `ps`, `kill` +- Command substitution: `$(...)` +- Variables and default expansion: `$VAR`, `${VAR}`, `${VAR:-default}`, `$!` +- Globbing +- Here-documents +- Functions and `local` +- `if` / `elif` / `else` +- `while`, `until`, `for ... in ...`, and C-style `for ((...))` +- Path-like command invocation such as `/bin/ls` + +Not supported: +- A full bash or POSIX shell grammar +- Host subprocess execution for ordinary commands +- Full TTY semantics or real OS job control +- Many advanced bash compatibility edge cases + +## Commands + +All built-ins support `--help`, and most also support `-h`. + +Core built-in coverage includes: +- File operations: `cat`, `cp`, `ln`, `ls`, `mkdir`, `mv`, `readlink`, `rm`, `rmdir`, `stat`, `touch`, `chmod`, `file`, `tree`, `diff` +- Text processing: `grep`, `rg`, `head`, `tail`, `wc`, `sort`, `uniq`, `cut`, `tr`, `awk`, `sed`, `xargs`, `printf`, `base64`, `sha256sum`, `sha1sum`, `md5sum` +- Data tools: `jq`, `yq`, `xan` +- Compression and archives: `gzip`, `gunzip`, `zcat`, `zip`, `unzip`, `tar` +- Navigation and environment: `basename`, `cd`, `dirname`, `du`, `echo`, `env`, `export`, `find`, `printenv`, `pwd`, `tee` +- Utilities: `clear`, `date`, `false`, `fg`, `help`, `history`, `jobs`, `kill`, `ps`, `seq`, `sleep`, `time`, `timeout`, `true`, `wait`, `whoami`, `which` +- Network commands: `curl`, `wget`, `html-to-markdown` + +Optional command sets: +- `sqlite3` via `BashSQLite` +- `python3` / `python` via `BashPython` +- `git` via `BashGit` +- `secrets` / `secret` via `BashSecrets` ## Testing +Run the test suite with: + ```bash swift test ``` -The project currently includes parser, filesystem, integration, and command coverage tests. - -## Roadmap - -### Priority (next) -1. `curl` advanced parity: cookie-jar/edge parsing, multipart/upload depth, verbose/error-code alignment -2. `xargs` advanced GNU parity: size limits, prompt mode, delimiter/empty-input edge semantics -3. `html-to-markdown` robustness: malformed HTML recovery and richer table semantics (`colspan`/`rowspan`/alignment) -4. `sqlite3` advanced parity: `-box`, `-html`, `-quote`, `-tabs`, dot-commands, and shell-level compatibility polish - -### Deferred for later milestones -- `git` parity expansion -- query engine parity expansion for `jq` / `yq` (functions, assignments, richer streaming behavior) -- command edge-case parity for file utilities (`cp`, `mv`, `ln`, `readlink`, `touch`) -- `python3` advanced parity (broader CLI flags, richer stdlib/package parity, hardening and execution controls) +The repository includes parser, filesystem, integration, command coverage, and optional-module tests. From ecc3f490478056d1535138dd66d4073a34931066 Mon Sep 17 00:00:00 2001 From: Zac White Date: Mon, 23 Mar 2026 15:14:52 -0700 Subject: [PATCH 14/14] Added a pr check --- .github/workflows/pr-build.yml | 42 ++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/pr-build.yml diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml new file mode 100644 index 0000000..9faec3d --- /dev/null +++ b/.github/workflows/pr-build.yml @@ -0,0 +1,42 @@ +name: PR Build + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + swift-build: + name: Swift Build + runs-on: macos-latest + timeout-minutes: 15 + + permissions: + actions: read + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up Swift + uses: swift-actions/setup-swift@v3 + with: + swift-version: "6.2" + + - name: Show Swift version + run: swift --version + + - name: Build package and tests + shell: bash + run: swift build --build-tests