diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml new file mode 100644 index 00000000..9faec3d8 --- /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 diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 95721a6f..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,234 +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: -- `Sources/Bash/FS/ShellFilesystem.swift` - -Rootless-session protocol: -- `Sources/Bash/FS/SessionConfigurableFilesystem.swift` - -Implementations: -- `Sources/Bash/FS/ReadWriteFilesystem.swift` - - Real disk I/O with jail to configured root. -- `Sources/Bash/FS/InMemoryFilesystem.swift` - - Pure in-memory tree. -- `Sources/Bash/FS/SandboxFilesystem.swift` - - Root chooser (`documents`, `caches`, `temporary`, app group, custom URL), delegates to read-write backing. -- `Sources/Bash/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` - -Path + jail utilities: -- `Sources/Bash/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. Conform to `SessionConfigurableFilesystem` if rootless `BashSession(options:)` should be supported. -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/Package.resolved b/Package.resolved index 561d5bad..09dcdfab 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fbb858abd7fcfd0fd009041b262ab4f8513932ad68db646cf230ec1aa94451e6", + "originHash" : "67d3a10de52cc088d55e494edf0ac4061befdc4fd1cbd416594819f8a140a7b7", "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" : "f68074337d57acdf1b2b52ed457c15dc68184e24", + "version" : "0.2.0" + } + }, { "identity" : "yams", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index ced06ca1..b9380283 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.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"), ], @@ -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 da63d513..ef6dd0ae 100644 --- a/README.md +++ b/README.md @@ -1,40 +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. - -## 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) -- [Public API](#public-api) -- [How It Works](#how-it-works) -- [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 @@ -49,20 +33,19 @@ Development of `Bash.swift` was approached very similarly to [just-bash](https:/ ] ``` -`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. +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. -If you include optional products, remember to register their commands at runtime (`registerSQLite3`, `registerPython`, `registerGit`, `registerSecrets`). - -## Platform Support - -Current package platforms: +Supported package platforms: - macOS 13+ - iOS 16+ - tvOS 16+ @@ -85,435 +68,215 @@ let piped = await session.run("echo hello | tee out.txt > copy.txt") print(piped.exitCode) // 0 ``` -Optional `sqlite3` registration: +For isolated per-run overrides without mutating the session's persisted shell state: + +```swift +let scoped = await session.run( + "pwd && echo $MODE", + options: RunOptions( + environment: ["MODE": "preview"], + currentDirectory: "/tmp" + ) +) +``` + +## 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). - -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*`. -`pip` and arbitrary native extension loading are non-goals in this runtime profile. +`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). -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 -await session.registerSecrets() -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) +let provider = AppleKeychainSecretsProvider() +await session.registerSecrets(provider: provider) +let ref = await session.run( + "secrets put --service app --account api", + stdin: Data("token".utf8) +) ``` -`BashSecrets` defaults to Apple Keychain generic-password storage through Security.framework and emits opaque `secretref:v1:...` references. +`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. -For harness/tooling flows where the model should only handle references, use the `Secrets` API directly: +## Workspace Package -```swift -let ref = try await Secrets.putGenericPassword( - service: "app", - account: "api", - value: Data("token".utf8) -) +`Bash` sits on top of a reusable `Workspace` package. If you only need filesystem and workspace tooling, use `Workspace` directly instead of `BashSession`. -// Resolve inside trusted tool code, not in model-visible shell output. -let secretValue = try await Secrets.resolveReference(ref) -``` - -For secret-aware command execution/redaction inside `BashSession`, configure a resolver and policy: +Example: ```swift -let options = SessionOptions( - filesystem: ReadWriteFilesystem(), - layout: .unixLike, - secretPolicy: .strict, - secretResolver: BashSecretsReferenceResolver() +import Workspace + +let filesystem = PermissionedFileSystem( + base: try OverlayFilesystem(rootDirectory: workspaceRoot), + authorizer: PermissionAuthorizer { request in + switch request.operation { + case .readFile, .listDirectory, .stat: + return .allowForSession + default: + return .deny(message: "write access denied") + } + } ) -let session = try await BashSession(rootDirectory: root, options: options) -``` -Policies: -- `.off`: no automatic secret-reference resolution/redaction in builtins -- `.resolveAndRedact`: resolve refs (where supported) and redact/replace secrets in output -- `.strict`: like `.resolveAndRedact`, plus blocks high-risk flows like `secrets get --reveal` +let workspace = Workspace(filesystem: filesystem) +let tree = try await workspace.summarizeTree("/workspace", maxDepth: 2) +``` -## Public API +## API Summary -### `BashSession` +Primary entry point: ```swift 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 } -} -``` - -### `CommandResult` - -```swift -public struct CommandResult { - public var stdout: Data - public var stderr: Data - public var exitCode: Int32 - - public var stdoutString: String { get } - public var stderrString: String { get } } ``` -### `SessionOptions` - -```swift -public struct SessionOptions { - public var filesystem: any ShellFilesystem - public var layout: SessionLayout - public var initialEnvironment: [String: String] - public var enableGlobbing: Bool - public var maxHistory: Int - public var secretPolicy: SecretHandlingPolicy - public var secretResolver: (any SecretReferenceResolving)? - public var secretOutputRedactor: any SecretOutputRedacting -} -``` - -Defaults: -- `filesystem`: `ReadWriteFilesystem()` -- `layout`: `.unixLike` -- `initialEnvironment`: `[:]` -- `enableGlobbing`: `true` -- `maxHistory`: `1000` -- `secretPolicy`: `.off` -- `secretResolver`: `nil` -- `secretOutputRedactor`: `DefaultSecretOutputRedactor()` - -Available filesystem implementations: -- `ReadWriteFilesystem`: root-jail wrapper over real disk I/O. -- `InMemoryFilesystem`: fully in-memory filesystem with no disk writes. -- `SandboxFilesystem`: resolves app container-style roots (`documents`, `caches`, `temporary`, app group, custom URL). -- `SecurityScopedFilesystem`: URL/bookmark-backed filesystem for security-scoped access. - -### `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. - -### 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) +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 + +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` + +## Security Model + +`Bash.swift` is a practical execution environment, not a hardened sandbox. + +Current hardening layers include: +- 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. -- `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. -- 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. +- 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` -Rootless session init example: +Rootless session example: ```swift -let inMemory = SessionOptions(filesystem: InMemoryFilesystem()) -let session = try await BashSession(options: inMemory) +let options = SessionOptions(filesystem: InMemoryFilesystem(), layout: .unixLike) +let session = try await BashSession(options: options) ``` -`BashSession.init(options:)` works with filesystems that can self-configure for a session (`SessionConfigurableFilesystem`), such as `InMemoryFilesystem`, `SandboxFilesystem`, and `SecurityScopedFilesystem`. - -You can provide a custom filesystem by implementing `ShellFilesystem`. - -### Filesystem Platform Matrix - -| Filesystem | macOS | iOS | Catalyst | tvOS/watchOS | -| --- | --- | --- | --- | --- | -| `ReadWriteFilesystem` | supported | supported | supported | supported | -| `InMemoryFilesystem` | 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 | +## Shell Scope -### Security-Scoped Bookmark Flow - -```swift -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. -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`, `ref`, `get`, `delete`, `run`; Keychain generic-password backend with reference-first flows (`secretref:v1:...`) 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 ` | -| `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 `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. - -## 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 -``` - -Run `general` with static command plans: - -```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. diff --git a/Sources/Bash/BashSession+ControlFlow.swift b/Sources/Bash/BashSession+ControlFlow.swift new file mode 100644 index 00000000..30be9901 --- /dev/null +++ b/Sources/Bash/BashSession+ControlFlow.swift @@ -0,0 +1,1667 @@ +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 (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, + 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 let failure = await executionControlStore?.recordLoopIteration( + loopName: "for", + iteration: iterations + ) { + combinedErr.append(Data("\(failure.message)\n".utf8)) + lastExitCode = failure.exitCode + 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 + 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 + } + + 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 = WorkspacePath( + normalizing: value, + relativeTo: WorkspacePath(normalizing: 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 = WorkspacePath( + normalizing: target, + relativeTo: WorkspacePath(normalizing: 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 = WorkspacePath( + normalizing: target, + relativeTo: WorkspacePath(normalizing: 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 = WorkspacePath( + normalizing: target, + relativeTo: WorkspacePath(normalizing: 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: WorkspacePath.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 { + 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 + var index = commandLine.startIndex + 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 { + 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 { + 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, + failure: nil + ) + } + + let parsed: ParsedLine + do { + parsed = try ShellParser.parse(nested.commandLine) + } catch let shellError as ShellError { + return CommandSubstitutionOutcome( + commandLine: "", + stderr: nested.stderr, + error: shellError, + failure: nil + ) + } catch { + return CommandSubstitutionOutcome( + commandLine: "", + stderr: nested.stderr, + error: .parserError("\(error)"), + failure: nil + ) + } + + 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, + failure: 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 b5ba7be4..4a2ee65b 100644 --- a/Sources/Bash/BashSession.swift +++ b/Sources/Bash/BashSession.swift @@ -1,15 +1,21 @@ import Foundation +import Workspace public final actor BashSession { - private let filesystemStore: any ShellFilesystem + let filesystemStore: any FileSystem private let options: SessionOptions - private let jobManager: ShellJobManager - - private var currentDirectoryStore: String - private var environmentStore: [String: String] + 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] private var historyStore: [String] private var commandRegistry: [String: AnyBuiltinCommand] - private var shellFunctionStore: [String: String] + var shellFunctionStore: [String: String] public var currentDirectory: String { currentDirectoryStore @@ -21,32 +27,181 @@ 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) } 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) } 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 + let executionControl = ExecutionControl( + limits: options.executionLimits ?? self.options.executionLimits, + cancellationCheck: options.cancellationCheck + ) + guard usesTemporaryState else { + return await runWithExecutionControl( + commandLine, + stdin: options.stdin, + executionControl: executionControl + ) + } + + let savedCurrentDirectory = currentDirectoryStore + let savedEnvironment = environmentStore + let savedFunctions = shellFunctionStore + + if let overrideDirectory = options.currentDirectory { + do { + try validateWorkspacePath(overrideDirectory) + } catch { + return CommandResult( + stdout: Data(), + stderr: Data("\(error)\n".utf8), + exitCode: 2 + ) + } + } + + if options.replaceEnvironment { + environmentStore = [:] + } + + if let overrideDirectory = options.currentDirectory { + currentDirectoryStore = normalizeWorkspacePath( + 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 runWithExecutionControl( + commandLine, + stdin: options.stdin, + executionControl: executionControl + ) + + currentDirectoryStore = savedCurrentDirectory + environmentStore = savedEnvironment + shellFunctionStore = savedFunctions + 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, + 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)) @@ -131,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 { @@ -146,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: @@ -154,7 +331,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) } } } @@ -164,24 +341,32 @@ 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 = ShellPermissionAuthorizer( + networkPolicy: options.networkPolicy, + handler: options.permissionHandler + ) + executionControlStore = nil + secretPolicyStore = options.secretPolicy + secretResolverStore = options.secretResolver + secretOutputRedactorStore = options.secretOutputRedactor commandRegistry = [:] shellFunctionStore = [:] @@ -231,101 +416,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, @@ -333,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( @@ -349,6 +440,8 @@ public final actor BashSession { shellFunctions: shellFunctions, enableGlobbing: options.enableGlobbing, jobControl: jobControl, + permissionAuthorizer: permissionAuthorizer, + executionControl: executionControlStore, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -372,7 +465,7 @@ public final actor BashSession { return execution } - private func executeStandardCommandLine( + func executeStandardCommandLine( _ commandLine: String, stdin: Data ) async -> CommandResult { @@ -398,2639 +491,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 } - } - } diff --git a/Sources/Bash/Commands/BuiltinCommand.swift b/Sources/Bash/Commands/BuiltinCommand.swift index 0743a553..9c8fb85f 100644 --- a/Sources/Bash/Commands/BuiltinCommand.swift +++ b/Sources/Bash/Commands/BuiltinCommand.swift @@ -1,10 +1,64 @@ 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: ShellPermissionAuthorizing { + private let base: any ShellPermissionAuthorizing + private let clock: EffectiveWallClock + + init(base: any ShellPermissionAuthorizing, clock: EffectiveWallClock) { + self.base = base + self.clock = clock + } + + func authorize(_ request: ShellPermissionRequest) async -> ShellPermissionDecision { + 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] - public let filesystem: any ShellFilesystem + public let filesystem: any FileSystem public let enableGlobbing: Bool public let secretPolicy: SecretHandlingPolicy public let secretResolver: (any SecretReferenceResolving)? @@ -19,11 +73,13 @@ public struct CommandContext: Sendable { public var stderr: Data let secretTracker: SecretExposureTracker? let jobControl: (any ShellJobControlling)? + 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, @@ -52,14 +108,16 @@ public struct CommandContext: Sendable { stdout: stdout, stderr: stderr, secretTracker: nil, - jobControl: nil + jobControl: nil, + permissionAuthorizer: ShellPermissionAuthorizer(), + executionControl: nil ) } init( commandName: String, arguments: [String], - filesystem: any ShellFilesystem, + filesystem: any FileSystem, enableGlobbing: Bool, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, @@ -72,7 +130,9 @@ public struct CommandContext: Sendable { stdout: Data = Data(), stderr: Data = Data(), secretTracker: SecretExposureTracker?, - jobControl: (any ShellJobControlling)? = nil + jobControl: (any ShellJobControlling)? = nil, + permissionAuthorizer: any ShellPermissionAuthorizing = ShellPermissionAuthorizer(), + executionControl: ExecutionControl? = nil ) { self.commandName = commandName self.arguments = arguments @@ -90,6 +150,8 @@ public struct CommandContext: Sendable { self.stderr = stderr self.secretTracker = secretTracker self.jobControl = jobControl + self.permissionAuthorizer = permissionAuthorizer + self.executionControl = executionControl } public mutating func writeStdout(_ string: String) { @@ -100,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 { @@ -168,6 +234,32 @@ public struct CommandContext: Sendable { return value } + public func requestPermission( + _ request: ShellPermissionRequest + ) async -> ShellPermissionDecision { + await authorizePermissionRequest( + request, + using: permissionAuthorizer, + pausing: executionControl + ) + } + + public func requestNetworkPermission( + url: String, + method: String + ) async -> ShellPermissionDecision { + await requestPermission( + ShellPermissionRequest( + command: commandName, + kind: .network(ShellNetworkPermissionRequest(url: url, method: method)) + ) + ) + } + + public var permissionDelegate: any ShellPermissionAuthorizing { + permissionAuthorizer + } + public mutating func runSubcommand( _ argv: [String], stdin: Data? = nil @@ -181,6 +273,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 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) @@ -196,10 +301,30 @@ public struct CommandContext: Sendable { } let commandArgs = Array(argv.dropFirst()) + let effectiveExecutionControl = executionControlOverride ?? executionControl + let effectivePermissionAuthorizer = permissionAuthorizerOverride ?? permissionAuthorizer + let childFilesystem = ShellPermissionedFileSystem( + base: ShellPermissionedFileSystem.unwrap(filesystem), + commandName: commandName, + permissionAuthorizer: effectivePermissionAuthorizer, + executionControl: effectiveExecutionControl + ) + if let failure = await effectiveExecutionControl?.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, - filesystem: filesystem, + filesystem: childFilesystem, enableGlobbing: enableGlobbing, secretPolicy: secretPolicy, secretResolver: secretResolver, @@ -210,7 +335,9 @@ public struct CommandContext: Sendable { environment: environment, stdin: stdin ?? self.stdin, secretTracker: secretTracker, - jobControl: jobControl + jobControl: jobControl, + permissionAuthorizer: effectivePermissionAuthorizer, + executionControl: effectiveExecutionControl ) let exitCode = await implementation.runCommand(&childContext, commandArgs) @@ -221,9 +348,77 @@ 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)] + return commandRegistry[WorkspacePath.basename(commandName)] } if let direct = commandRegistry[commandName] { @@ -231,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 fea7e38a..c6dc23f9 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 } @@ -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/CompressionCommands.swift b/Sources/Bash/Commands/CompressionCommands.swift index 9402e453..b6ca362c 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 b3f55ba7..6ee53db6 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 fe5f715b..b649a670 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 9a9630ba..20fe267e 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 0da2588b..b8fbeafa 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 28b65422..9fade6c8 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 { @@ -36,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) } @@ -65,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") @@ -91,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 @@ -124,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 { @@ -357,7 +358,7 @@ struct FindCommand: BuiltinCommand { private struct FindRuntime { var pendingBatchExecPaths: [Int: [String]] = [:] - var pendingDirectoryDeletes: Set = [] + var pendingDirectoryDeletes: Set = [] var hadError = false } @@ -784,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, @@ -820,7 +821,7 @@ struct FindCommand: BuiltinCommand { shouldPrune = evalResult.shouldPrune if evalResult.matches, parsed.useDefaultPrint { - context.writeStdout("\(path)\n") + context.writeStdout("\(path.string)\n") } } @@ -846,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, @@ -858,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, @@ -953,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, @@ -963,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): @@ -1035,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 { @@ -1050,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): @@ -1082,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) @@ -1102,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 { @@ -1282,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 { @@ -1291,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] @@ -1312,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": @@ -1335,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 e8c1a4bb..56b4f6b5 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, @@ -467,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) @@ -482,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) @@ -512,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) @@ -524,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) @@ -608,7 +630,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 @@ -622,7 +644,7 @@ struct CurlCommand: BuiltinCommand { ) ) } catch { - context.writeStderr("curl: \(filesystemPath): \(error)\n") + context.writeStderr("curl: \(filesystemPath.string): \(error)\n") return .failure(37) } } @@ -825,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) @@ -835,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) @@ -843,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) @@ -852,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) @@ -869,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 @@ -1000,7 +981,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) @@ -1010,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) @@ -1181,6 +1165,30 @@ struct CurlCommand: BuiltinCommand { return HTTPCookie(properties: properties) } + @discardableResult + private static func authorizeNetworkRequest( + url: URL, + method: String, + context: inout CommandContext, + options: Options + ) async -> Int32? { + let decision = await context.requestNetworkPermission( + url: url.absoluteString, + method: method + ) + 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, @@ -1340,7 +1348,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 7751af16..9c9276fc 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 { @@ -68,8 +69,8 @@ struct DiffCommand: BuiltinCommand { } private static func compareDirectories( - leftRoot: String, - rightRoot: String, + leftRoot: WorkspacePath, + rightRoot: WorkspacePath, leftLabel: String, rightLabel: String, unified: Bool, @@ -98,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, @@ -121,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 } @@ -137,8 +139,8 @@ struct DiffCommand: BuiltinCommand { } private static func compareFiles( - leftPath: String, - rightPath: String, + leftPath: WorkspacePath, + rightPath: WorkspacePath, leftLabel: String, rightLabel: String, unified: Bool, @@ -198,4 +200,3 @@ struct DiffCommand: BuiltinCommand { return lines } } - diff --git a/Sources/Bash/Commands/Text/SearchCommands.swift b/Sources/Bash/Commands/Text/SearchCommands.swift index aacabc1e..74c7b493 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 88389069..5498d740 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) - } - - group.addTask { - try? await Task.sleep(nanoseconds: timeoutNanos) - return .timedOut - } - - let first = await group.next() ?? .timedOut - group.cancelAll() - return first - } + let sub = await context.runSubcommandIsolated( + options.command, + stdin: context.stdin, + wallClockTimeout: options.seconds + ) - 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 } } } @@ -929,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/PathUtils.swift b/Sources/Bash/Core/PathUtils.swift deleted file mode 100644 index 751f2a73..00000000 --- a/Sources/Bash/Core/PathUtils.swift +++ /dev/null @@ -1,99 +0,0 @@ -import Foundation - -enum PathUtils { - 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).. CommandResult { + if let failure = await executionControl?.checkpoint() { + return CommandResult( + stdout: Data(), + stderr: Data("\(failure.message)\n".utf8), + exitCode: failure.exitCode + ) + } + + let baseFilesystem = ShellPermissionedFileSystem.unwrap(filesystem) + let initialCommandName = command.words.first?.rawValue.isEmpty == false + ? command.words.first!.rawValue + : "shell" + let expansionFilesystem = ShellPermissionedFileSystem( + base: baseFilesystem, + commandName: initialCommandName, + permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl + ) + var input = stdin var stderr = Data() @@ -218,19 +256,24 @@ enum ShellExecutor { if let hereDocument = redirection.hereDocument { let expandedHereDocument = await expandHereDocumentBody( hereDocument, - filesystem: filesystem, + filesystem: expansionFilesystem, currentDirectory: currentDirectory, environment: environment, history: history, commandRegistry: commandRegistry, 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) @@ -242,14 +285,16 @@ enum ShellExecutor { guard let targetWord = redirection.target else { continue } let target = await firstExpansion( word: targetWord, - filesystem: filesystem, + filesystem: expansionFilesystem, currentDirectory: currentDirectory, environment: environment, enableGlobbing: enableGlobbing ) do { - input = try await filesystem.readFile(path: PathUtils.normalize(path: target, currentDirectory: currentDirectory)) + input = try await expansionFilesystem.readFile( + path: WorkspacePath(normalizing: target, relativeTo: WorkspacePath(normalizing: currentDirectory)) + ) } catch { stderr.append(Data("\(target): \(error)\n".utf8)) return CommandResult(stdout: Data(), stderr: stderr, exitCode: 1) @@ -258,7 +303,7 @@ enum ShellExecutor { let expandedWords = await expandWords( command.words, - filesystem: filesystem, + filesystem: expansionFilesystem, currentDirectory: currentDirectory, environment: environment, enableGlobbing: enableGlobbing @@ -269,6 +314,12 @@ enum ShellExecutor { } let commandArgs = Array(expandedWords.dropFirst()) + let commandFilesystem = ShellPermissionedFileSystem( + base: baseFilesystem, + commandName: commandName, + permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl + ) var result: CommandResult if commandArgs.isEmpty, let assignment = parseAssignment(commandName) { @@ -277,10 +328,18 @@ 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, - filesystem: filesystem, + filesystem: commandFilesystem, enableGlobbing: enableGlobbing, secretPolicy: secretPolicy, secretResolver: secretResolver, @@ -291,7 +350,9 @@ enum ShellExecutor { environment: environment, stdin: input, secretTracker: secretTracker, - jobControl: jobControl + jobControl: jobControl, + permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl ) let exitCode = await implementation.runCommand(&context, commandArgs) @@ -307,7 +368,7 @@ enum ShellExecutor { functionBody, functionArguments: commandArgs, stdin: input, - filesystem: filesystem, + filesystem: baseFilesystem, currentDirectory: ¤tDirectory, environment: &environment, history: history, @@ -315,6 +376,8 @@ enum ShellExecutor { shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, jobControl: jobControl, + permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -331,22 +394,20 @@ enum ShellExecutor { guard let targetWord = redirection.target else { continue } let target = await firstExpansion( word: targetWord, - filesystem: filesystem, + filesystem: commandFilesystem, currentDirectory: currentDirectory, environment: environment, enableGlobbing: enableGlobbing ) do { - let path = PathUtils.normalize(path: target, currentDirectory: currentDirectory) - let redactedOutput = await redactForExternalOutput( - result.stdout, - secretTracker: secretTracker, - secretOutputRedactor: secretOutputRedactor + let path = WorkspacePath( + normalizing: target, + relativeTo: WorkspacePath(normalizing: currentDirectory) ) - try await filesystem.writeFile( + try await commandFilesystem.writeFile( path: path, - data: redactedOutput, + data: result.stdout, append: redirection.type == .stdoutAppend ) result.stdout.removeAll(keepingCapacity: true) @@ -358,22 +419,20 @@ enum ShellExecutor { guard let targetWord = redirection.target else { continue } let target = await firstExpansion( word: targetWord, - filesystem: filesystem, + filesystem: commandFilesystem, currentDirectory: currentDirectory, environment: environment, enableGlobbing: enableGlobbing ) do { - let path = PathUtils.normalize(path: target, currentDirectory: currentDirectory) - let redactedStderr = await redactForExternalOutput( - result.stderr, - secretTracker: secretTracker, - secretOutputRedactor: secretOutputRedactor + let path = WorkspacePath( + normalizing: target, + relativeTo: WorkspacePath(normalizing: currentDirectory) ) - try await filesystem.writeFile( + try await commandFilesystem.writeFile( path: path, - data: redactedStderr, + data: result.stderr, append: redirection.type == .stderrAppend ) result.stderr.removeAll(keepingCapacity: true) @@ -388,28 +447,21 @@ enum ShellExecutor { guard let targetWord = redirection.target else { continue } let target = await firstExpansion( word: targetWord, - filesystem: filesystem, + filesystem: commandFilesystem, currentDirectory: currentDirectory, environment: environment, enableGlobbing: enableGlobbing ) do { - let path = PathUtils.normalize(path: target, currentDirectory: currentDirectory) - let redactedStdout = await redactForExternalOutput( - result.stdout, - secretTracker: secretTracker, - secretOutputRedactor: secretOutputRedactor - ) - let redactedStderr = await redactForExternalOutput( - result.stderr, - secretTracker: secretTracker, - secretOutputRedactor: secretOutputRedactor + let path = WorkspacePath( + normalizing: target, + relativeTo: WorkspacePath(normalizing: currentDirectory) ) var combined = Data() - combined.append(redactedStdout) - combined.append(redactedStderr) - try await filesystem.writeFile( + combined.append(result.stdout) + combined.append(result.stderr) + try await commandFilesystem.writeFile( path: path, data: combined, append: redirection.type == .stdoutAndErrAppend @@ -432,7 +484,7 @@ enum ShellExecutor { _ body: String, functionArguments: [String], stdin: Data, - filesystem: any ShellFilesystem, + filesystem: any FileSystem, currentDirectory: inout String, environment: inout [String: String], history: [String], @@ -440,6 +492,8 @@ enum ShellExecutor { shellFunctions: [String: String], enableGlobbing: Bool, jobControl: (any ShellJobControlling)?, + permissionAuthorizer: any ShellPermissionAuthorizing, + executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -456,6 +510,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 @@ -477,6 +539,8 @@ enum ShellExecutor { shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, jobControl: jobControl, + permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -501,6 +565,8 @@ enum ShellExecutor { environment[functionDepthKey] = String(previousDepth) } + await executionControl?.popFunction() + return execution.result } @@ -546,7 +612,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] } @@ -555,7 +621,7 @@ enum ShellExecutor { } if commandName.contains("/") { - return registry[PathUtils.basename(commandName)] + return registry[WorkspacePath.basename(commandName)] } return nil @@ -727,7 +793,7 @@ enum ShellExecutor { private static func expandWords( _ words: [ShellWord], - filesystem: any ShellFilesystem, + filesystem: any FileSystem, currentDirectory: String, environment: [String: String], enableGlobbing: Bool @@ -750,7 +816,7 @@ enum ShellExecutor { private static func firstExpansion( word: ShellWord, - filesystem: any ShellFilesystem, + filesystem: any FileSystem, currentDirectory: String, environment: [String: String], enableGlobbing: Bool @@ -767,7 +833,7 @@ enum ShellExecutor { private static func expandWord( _ word: ShellWord, - filesystem: any ShellFilesystem, + filesystem: any FileSystem, currentDirectory: String, environment: [String: String], enableGlobbing: Bool @@ -783,13 +849,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] } @@ -904,13 +973,15 @@ 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 ShellPermissionAuthorizing, + executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -920,7 +991,8 @@ enum ShellExecutor { return TextExpansionOutcome( text: hereDocument.body, stderr: Data(), - error: nil + error: nil, + failure: nil ) } @@ -933,6 +1005,8 @@ enum ShellExecutor { commandRegistry: commandRegistry, shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, + permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -942,13 +1016,15 @@ 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 ShellPermissionAuthorizing, + executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -974,6 +1050,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 == "\\" { @@ -1032,6 +1117,8 @@ enum ShellExecutor { commandRegistry: commandRegistry, shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, + permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -1043,7 +1130,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 @@ -1052,13 +1148,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 ) } } @@ -1115,19 +1213,22 @@ enum ShellExecutor { return TextExpansionOutcome( text: output, stderr: stderr, - error: nil + error: nil, + failure: nil ) } 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 ShellPermissionAuthorizing, + executionControl: ExecutionControl?, secretPolicy: SecretHandlingPolicy, secretResolver: (any SecretReferenceResolving)?, secretTracker: SecretExposureTracker?, @@ -1140,6 +1241,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 { @@ -1200,13 +1310,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 ) } } @@ -1233,6 +1345,8 @@ enum ShellExecutor { commandRegistry: commandRegistry, shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, + permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -1244,7 +1358,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 @@ -1253,13 +1376,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 ) } } @@ -1272,24 +1397,36 @@ enum ShellExecutor { return TextExpansionOutcome( text: output, stderr: stderr, - error: nil + error: nil, + failure: nil ) } 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 ShellPermissionAuthorizing, + 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, @@ -1299,11 +1436,22 @@ enum ShellExecutor { commandRegistry: commandRegistry, 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 } @@ -1315,13 +1463,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 ) } @@ -1336,6 +1486,8 @@ enum ShellExecutor { shellFunctions: shellFunctions, enableGlobbing: enableGlobbing, jobControl: nil, + permissionAuthorizer: permissionAuthorizer, + executionControl: executionControl, secretPolicy: secretPolicy, secretResolver: secretResolver, secretTracker: secretTracker, @@ -1348,7 +1500,8 @@ enum ShellExecutor { return TextExpansionOutcome( text: trimmingTrailingNewlines(from: execution.result.stdoutString), stderr: stderr, - error: nil + error: nil, + failure: nil ) } @@ -1635,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/Bash/Core/ShellLexer.swift b/Sources/Bash/Core/ShellLexer.swift index 4a9da8b4..1f169e4d 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/FS/BookmarkStore.swift b/Sources/Bash/FS/BookmarkStore.swift deleted file mode 100644 index 8f829a34..00000000 --- a/Sources/Bash/FS/BookmarkStore.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -public protocol BookmarkStore: Sendable { - func saveBookmark(_ data: Data, for id: String) async throws - func loadBookmark(for id: String) async throws -> 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 a815271b..00000000 --- a/Sources/Bash/FS/InMemoryFilesystem.swift +++ /dev/null @@ -1,496 +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 { - 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] { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 resolvePath(path: PathUtils.normalize(path: path, currentDirectory: "/"), followFinalSymlink: true, symlinkDepth: 0) - } - - public func exists(path: String) async -> Bool { - do { - 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] { - 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/ReadWriteFilesystem.swift b/Sources/Bash/FS/ReadWriteFilesystem.swift deleted file mode 100644 index 1ae61b77..00000000 --- a/Sources/Bash/FS/ReadWriteFilesystem.swift +++ /dev/null @@ -1,289 +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 { - 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] { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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] { - 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 1f6a091e..00000000 --- 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 a2de64c5..00000000 --- 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 f747e376..00000000 --- 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 b266d26e..00000000 --- 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 1a2ae2c8..00000000 --- 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/ExecutionControl.swift b/Sources/Bash/Support/ExecutionControl.swift new file mode 100644 index 00000000..4c15c6ba --- /dev/null +++ b/Sources/Bash/Support/ExecutionControl.swift @@ -0,0 +1,161 @@ +import Foundation + +struct ExecutionFailure: Sendable { + let exitCode: Int32 + let message: String +} + +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, + cancellationCheck: (@Sendable () -> Bool)? = nil + ) { + 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") + } + 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) + } + + 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 new file mode 100644 index 00000000..fd900fa8 --- /dev/null +++ b/Sources/Bash/Support/Permissions.swift @@ -0,0 +1,499 @@ +import Foundation + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +public struct ShellPermissionRequest: Sendable, Hashable { + public enum Kind: Sendable, Hashable { + case network(ShellNetworkPermissionRequest) + case filesystem(ShellFilesystemPermissionRequest) + } + + public var command: String + public var kind: Kind + + public init(command: String, kind: Kind) { + self.command = command + self.kind = kind + } +} + +public struct ShellNetworkPermissionRequest: Sendable, Hashable { + public var url: String + public var method: String + + public init(url: String, method: String) { + self.url = url + self.method = method + } +} + +public enum ShellFilesystemPermissionOperation: 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 ShellFilesystemPermissionRequest: Sendable, Hashable { + 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 init( + operation: ShellFilesystemPermissionOperation, + 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 ShellNetworkPolicy: Sendable { + public static let disabled = ShellNetworkPolicy() + public static let unrestricted = ShellNetworkPolicy(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 + } + + var hasAllowlist: Bool { + !allowedHosts.isEmpty || !allowedURLPrefixes.isEmpty + } +} + +public enum ShellPermissionDecision: Sendable { + case allow + case allowForSession + case deny(message: String?) +} + +public protocol ShellPermissionAuthorizing: Sendable { + func authorize(_ request: ShellPermissionRequest) async -> ShellPermissionDecision +} + +actor ShellPermissionAuthorizer: ShellPermissionAuthorizing { + typealias Handler = @Sendable (ShellPermissionRequest) async -> ShellPermissionDecision + + private let networkPolicy: ShellNetworkPolicy + private let handler: Handler? + private var sessionAllows: Set = [] + + init( + networkPolicy: ShellNetworkPolicy = .disabled, + handler: Handler? = nil + ) { + self.networkPolicy = networkPolicy + self.handler = handler + } + + func authorize(_ request: ShellPermissionRequest) async -> ShellPermissionDecision { + 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 + } + + let decision = await handler(request) + if case .allowForSession = decision { + sessionAllows.insert(request) + return .allow + } + + return decision + } + + func authorize( + _ request: ShellPermissionRequest, + pausing executionControl: ExecutionControl? + ) async -> ShellPermissionDecision { + 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 + } +} + +func authorizePermissionRequest( + _ request: ShellPermissionRequest, + using authorizer: any ShellPermissionAuthorizing, + pausing executionControl: ExecutionControl? +) async -> ShellPermissionDecision { + if let authorizer = authorizer as? ShellPermissionAuthorizer { + 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: ShellPermissionRequest, + networkPolicy: ShellNetworkPolicy + ) -> String? { + switch request.kind { + case let .network(networkRequest): + denialMessage(for: networkRequest, networkPolicy: networkPolicy) + case .filesystem: + nil + } + } + + private static func denialMessage( + for request: ShellNetworkPermissionRequest, + networkPolicy: ShellNetworkPolicy + ) -> String? { + 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)'" + } + + return nil + } + + 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 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: "[]")) + + 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/ShellPermissionedFileSystem.swift b/Sources/Bash/Support/ShellPermissionedFileSystem.swift new file mode 100644 index 00000000..1a150d2d --- /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 fe3d1f71..d248f396 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 @@ -20,6 +21,55 @@ 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 var executionLimits: ExecutionLimits? + public var cancellationCheck: (@Sendable () -> Bool)? + + public init( + stdin: Data = Data(), + environment: [String: String] = [:], + replaceEnvironment: Bool = false, + 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 var maxWallClockDuration: TimeInterval? + + public init( + maxCommandCount: Int = 10_000, + maxFunctionDepth: Int = 100, + maxLoopIterations: Int = 10_000, + maxCommandSubstitutionDepth: Int = 32, + maxWallClockDuration: TimeInterval? = nil + ) { + self.maxCommandCount = maxCommandCount + self.maxFunctionDepth = maxFunctionDepth + self.maxLoopIterations = maxLoopIterations + self.maxCommandSubstitutionDepth = maxCommandSubstitutionDepth + self.maxWallClockDuration = maxWallClockDuration + } +} + public enum SessionLayout: Sendable { case unixLike case rootOnly @@ -90,21 +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: 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 public init( - filesystem: any ShellFilesystem = ReadWriteFilesystem(), + filesystem: any FileSystem = ReadWriteFilesystem(), layout: SessionLayout = .unixLike, initialEnvironment: [String: String] = [:], enableGlobbing: Bool = true, maxHistory: Int = 1_000, + networkPolicy: ShellNetworkPolicy = .disabled, + executionLimits: ExecutionLimits = .default, + permissionHandler: (@Sendable (ShellPermissionRequest) async -> ShellPermissionDecision)? = nil, secretPolicy: SecretHandlingPolicy = .off, secretResolver: (any SecretReferenceResolving)? = nil, secretOutputRedactor: any SecretOutputRedacting = DefaultSecretOutputRedactor() @@ -114,6 +170,9 @@ public struct SessionOptions: Sendable { self.initialEnvironment = initialEnvironment self.enableGlobbing = enableGlobbing self.maxHistory = maxHistory + self.networkPolicy = networkPolicy + self.executionLimits = executionLimits + self.permissionHandler = permissionHandler self.secretPolicy = secretPolicy self.secretResolver = secretResolver self.secretOutputRedactor = secretOutputRedactor @@ -128,6 +187,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 @@ -137,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/WorkspaceSupport.swift b/Sources/Bash/WorkspaceSupport.swift new file mode 100644 index 00000000..bb8309df --- /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/BashCPythonBridge/BashCPythonBridge.c b/Sources/BashCPythonBridge/BashCPythonBridge.c index 8492e1b8..f69f5f69 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 28ab8f7b..b80969e7 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 25af6a5d..5079bba4 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 @@ -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, @@ -550,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") { @@ -598,6 +610,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 @@ -867,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)) } } @@ -919,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) @@ -962,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) @@ -980,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 { @@ -1001,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( @@ -1041,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 @@ -1056,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] { @@ -1105,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) { @@ -1122,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( @@ -1166,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 345e0124..fc1f34f9 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? @@ -99,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() @@ -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 @@ -209,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 @@ -281,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, ] @@ -370,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: [:]) @@ -382,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)") @@ -392,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") } @@ -407,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 { @@ -481,6 +483,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 ShellPermissionAuthorizing)? + + func setContext( + commandName: String, + permissionAuthorizer: (any ShellPermissionAuthorizing)? + ) { + 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 = ShellPermissionRequest( + command: snapshot.commandName, + kind: .network(ShellNetworkPermissionRequest(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 ShellPermissionAuthorizing)?) { + 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 +612,7 @@ import json import os import posixpath import runpy +import socket import stat as _stat import sys import tempfile as _tempfile @@ -516,6 +620,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 +667,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 +1178,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 5b5a666d..ed3de2a6 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 5466d9a1..159232b5 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 ShellPermissionAuthorizing)? public init( + commandName: String, mode: PythonExecutionMode, source: String, scriptPath: String?, arguments: [String], currentDirectory: String, environment: [String: String], - stdin: String + stdin: String, + permissionAuthorizer: (any ShellPermissionAuthorizing)? = 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 } } @@ -49,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 @@ -110,7 +116,7 @@ struct UnsupportedPythonRuntime: PythonRuntime { func execute( request: PythonExecutionRequest, - filesystem: any ShellFilesystem + filesystem: any FileSystem ) async -> PythonExecutionResult { _ = request _ = filesystem diff --git a/Sources/BashSecrets/AppleKeychainSecretsRuntime.swift b/Sources/BashSecrets/AppleKeychainSecretsRuntime.swift index 4b1b292b..8b74d2de 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 8e9880c0..1d35913d 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 67a48cfc..b0bcd7d4 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 00000000..da6615dd --- /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 bb29cf5b..33d2a6d6 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 2b054c60..f578ca94 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 c7f8d440..b9638857 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/BashGitTests/GitCommandTests.swift b/Tests/BashGitTests/GitCommandTests.swift index eef56cba..65867d90 100644 --- a/Tests/BashGitTests/GitCommandTests.swift +++ b/Tests/BashGitTests/GitCommandTests.swift @@ -145,6 +145,36 @@ 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: ShellNetworkPolicy( + allowsHTTPRequests: true, + 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: ShellNetworkPolicy( + allowsHTTPRequests: true, + 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 90b4d70a..5d5a8357 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: ShellNetworkPolicy = .unrestricted, + permissionHandler: (@Sendable (ShellPermissionRequest) async -> ShellPermissionDecision)? = 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: ShellNetworkPolicy = .unrestricted, + permissionHandler: (@Sendable (ShellPermissionRequest) async -> ShellPermissionDecision)? = 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 8cbaaf88..c3b882c3 100644 --- a/Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift +++ b/Tests/BashPythonTests/CPythonRuntimeIntegrationTests.swift @@ -111,6 +111,49 @@ 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: ShellNetworkPolicy( + allowsHTTPRequests: true, + 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: ShellNetworkPolicy( + allowsHTTPRequests: true, + 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 + case .filesystem: + 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 1a02c2ff..ddca92d5 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: ShellNetworkPolicy = .unrestricted, + permissionHandler: (@Sendable (ShellPermissionRequest) async -> ShellPermissionDecision)? = 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: ShellNetworkPolicy = .unrestricted, + permissionHandler: (@Sendable (ShellPermissionRequest) async -> ShellPermissionDecision)? = 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 @@ -37,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/SecretsCommandTests.swift b/Tests/BashSecretsTests/SecretsCommandTests.swift index 04c1bb05..fe9649aa 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,22 +431,49 @@ 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") 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)" @@ -433,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 60881013..89e5210c 100644 --- a/Tests/BashSecretsTests/TestSupport.swift +++ b/Tests/BashSecretsTests/TestSupport.swift @@ -11,74 +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( - policy: SecretHandlingPolicy + 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, - 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 - } -} diff --git a/Tests/BashTests/FilesystemOptionsTests.swift b/Tests/BashTests/FilesystemOptionsTests.swift index 650e8de8..9d6be715 100644 --- a/Tests/BashTests/FilesystemOptionsTests.swift +++ b/Tests/BashTests/FilesystemOptionsTests.swift @@ -24,8 +24,112 @@ struct FilesystemOptionsTests { #expect(ls.stdoutString.contains("rootless.txt")) } - @Test("rootless session init rejects non-configurable filesystem") - func rootlessSessionInitRejectsNonConfigurableFilesystem() async { + @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: WorkspacePath(normalizing: "/note.txt"), + data: Data("native".utf8), + append: false + ) + await inMemoryFilesystem.reset() + + let info = FileInfo( + path: WorkspacePath(normalizing: "/note.txt"), + kind: .file, + size: 4, + permissions: POSIXPermissions(0o644), + modificationDate: nil + ) + let entry = DirectoryEntry(name: "note.txt", info: info) + let error = WorkspaceError.unsupported("native 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") + 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 await 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 await OverlayFilesystem(rootDirectory: workspaceRoot) + ), + MountableFilesystem.Mount( + mountPoint: "/docs", + filesystem: try await 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 unconfigured read-write filesystem") + func rootlessSessionInitRejectsUnconfiguredReadWriteFilesystem() async { do { _ = try await BashSession( options: SessionOptions( @@ -37,8 +141,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)") } @@ -69,22 +173,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)") @@ -112,12 +213,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)") @@ -130,7 +230,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() @@ -143,17 +242,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 7b9f42d9..8dc60b02 100644 --- a/Tests/BashTests/ParserAndFilesystemTests.swift +++ b/Tests/BashTests/ParserAndFilesystemTests.swift @@ -182,6 +182,16 @@ struct ParserAndFilesystemTests { #expect(read.stderrString.contains("invalid path")) } + @Test("workspace paths reject null bytes") + func workspacePathsRejectNullBytes() { + do { + _ = try WorkspacePath(validating: "/bad\u{0}name") + Issue.record("expected workspace path validation to reject null bytes") + } 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 c3c9933e..bcaa8fdb 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: [ShellPermissionRequest] = [] + + func record(_ request: ShellPermissionRequest) { + requests.append(request) + } + + func snapshot() -> [ShellPermissionRequest] { + requests + } +} + @Suite("Session Integration") struct SessionIntegrationTests { @Test("touch then ls mutates read-write filesystem") @@ -54,6 +66,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() @@ -1351,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") @@ -1479,6 +1546,378 @@ 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( + networkPolicy: .unrestricted, + 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") + case .filesystem: + Issue.record("expected network permission request") + } + } + + @Test("curl permission handler allow once does not persist") + 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 + } + ) + 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( + networkPolicy: .unrestricted, + 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( + networkPolicy: .unrestricted, + 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("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( + networkPolicy: ShellNetworkPolicy( + allowsHTTPRequests: true, + 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: ShellNetworkPolicy( + allowsHTTPRequests: true, + 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("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: ShellNetworkPolicy( + 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() @@ -1573,3 +2012,115 @@ 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")) + } + + @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")) + } +} diff --git a/Tests/BashTests/TestSupport.swift b/Tests/BashTests/TestSupport.swift index e2b7e456..53505be1 100644 --- a/Tests/BashTests/TestSupport.swift +++ b/Tests/BashTests/TestSupport.swift @@ -10,9 +10,11 @@ enum TestSupport { } static func makeSession( - filesystem: (any ShellFilesystem)? = nil, + filesystem: (any FileSystem)? = nil, layout: SessionLayout = .unixLike, - enableGlobbing: Bool = true + enableGlobbing: Bool = true, + networkPolicy: ShellNetworkPolicy = .disabled, + permissionHandler: (@Sendable (ShellPermissionRequest) async -> ShellPermissionDecision)? = nil ) async throws -> (session: BashSession, root: URL) { let root = try makeTempDirectory() let options = SessionOptions( @@ -20,7 +22,10 @@ enum TestSupport { layout: layout, initialEnvironment: [:], enableGlobbing: enableGlobbing, - maxHistory: 1_000 + maxHistory: 1_000, + networkPolicy: networkPolicy, + executionLimits: .default, + 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 8900c0a1..4d643131 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` | -| `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 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` |