diff --git a/.github/installers/cell.ps1 b/.github/installers/cell.ps1 index 402ed5181..232c4c268 100644 --- a/.github/installers/cell.ps1 +++ b/.github/installers/cell.ps1 @@ -44,6 +44,8 @@ if (-not $gatewayVersion) { Fail "$pin names no Gateway version" } $root = Join-Path $env:LOCALAPPDATA 'Runvil' $profileDir = Join-Path $env:USERPROFILE '.runvil' $workshopExe = Join-Path $root 'runvil-workshop.exe' +# The std Plugin's library installs with Workshop, in its Plugin directory. +$stdPlugin = Join-Path $root 'plugins\plugin_std.dll' $gatewayExe = Join-Path $root 'runvil-gateway.exe' $config = Join-Path $profileDir 'gateway.toml' $discovery = Join-Path $profileDir 'run\gateway.json' @@ -80,13 +82,41 @@ function Invoke-Setup([string[]]$Arguments, [switch]$DeadProxy) { } } +function Get-Started { + @(Get-Process -Name 'runvil-workshop', 'runvil-gateway' -ErrorAction SilentlyContinue) +} + +# Stops Workshop and every Gateway, and returns only when a fresh listing +# finds neither. Windows keeps a stopped process in the list, with its +# files still open, while it tears the process down, and `Wait-Process` +# returns before that ends. A listing of the model files taken in that +# time is stale. Workshop goes first, because it supervises its Gateway +# and starts another when it finds none. function Stop-Started { - $running = @(Get-Process -Name 'runvil-workshop', 'runvil-gateway' -ErrorAction SilentlyContinue) - foreach ($process in $running) { - Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue - } - foreach ($process in $running) { - Wait-Process -Id $process.Id -Timeout 30 -ErrorAction SilentlyContinue + $clock = [Diagnostics.Stopwatch]::StartNew() + while ($true) { + foreach ($name in 'runvil-workshop', 'runvil-gateway') { + $running = @(Get-Process -Name $name -ErrorAction SilentlyContinue) + foreach ($process in $running) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + } + foreach ($process in $running) { + Wait-Process -Id $process.Id -Timeout 30 -ErrorAction SilentlyContinue + } + } + $left = @(Get-Started) + if ($left.Count -eq 0) { + # Worth a line when it was slow: the next failure may be this. + if ($clock.Elapsed.TotalSeconds -ge 1) { + Write-Host ('cell: Workshop and the Gateway took {0:N1} seconds to stop' -f $clock.Elapsed.TotalSeconds) + } + return + } + if ($clock.Elapsed.TotalSeconds -ge 120) { + $names = ($left | ForEach-Object { "$($_.ProcessName) (pid $($_.Id))" }) -join ', ' + Fail "Workshop and the Gateway must stop, but after 120 seconds these still run: $names" + } + Start-Sleep -Milliseconds 100 } } @@ -100,6 +130,42 @@ function Get-ModelFiles { @(Get-ChildItem -LiteralPath (Join-Path $profileDir 'models') -Recurse -File -ErrorAction SilentlyContinue) } +# A file's length as the file reports it, or -1 when it cannot be opened. +# A directory listing reports the length from the last time the file was +# closed, which is 0 for a download that is still being written. +function Get-Length([string]$Path) { + try { + $stream = [IO.File]::Open($Path, 'Open', 'Read', 'ReadWrite, Delete') + } catch { + return -1 + } + try { + return $stream.Length + } finally { + $stream.Dispose() + } +} + +function Get-ModelState { + (@(Get-ModelFiles) | Sort-Object FullName | ForEach-Object { "$($_.FullName):$(Get-Length $_.FullName)" }) -join ',' +} + +# The model files and their lengths once nothing writes to them: two +# listings half a second apart that agree. The Gateway's first start +# begins the Speech to Text download, so a listing taken while a transfer +# still runs would differ from a later one through no act of the setup. +function Wait-ModelState { + $last = Get-ModelState + foreach ($attempt in 1..240) { + Start-Sleep -Milliseconds 500 + $now = Get-ModelState + if ($now -eq $last -and $now -notmatch ':-1(,|$)') { return $now } + $last = $now + } + $running = (Get-Started | ForEach-Object { "$($_.ProcessName) (pid $($_.Id))" }) -join ', ' + Fail "the model files must stop changing within 120 seconds, but they still change: [$last]; running: [$running]" +} + # The Speech to Text gate the setup writes for the Gateway's first start: # $null when absent (speech on), else the DWORD (0 = speech off). function Get-InstallStt { @@ -169,17 +235,19 @@ function Get-InstallState { $keys = @(Get-Item -Path 'HKCU:\Software\*\Runvil\Components' -ErrorAction SilentlyContinue) if ($keys.Count -ne 1) { Fail "exactly one HKCU:\Software\*\Runvil\Components key must hold the persisted selection; found $($keys.Count)" } $components = ($keys[0].GetValueNames() | Sort-Object | ForEach-Object { "$_=$($keys[0].GetValue($_))" }) -join ',' - $models = (@(Get-ModelFiles) | Sort-Object FullName | ForEach-Object { "$($_.FullName):$($_.Length)" }) -join ',' [pscustomobject]@{ Config = [Convert]::ToBase64String([IO.File]::ReadAllBytes($config)) - Models = $models + Models = Wait-ModelState Components = $components } } function Assert-SameState($Before, $After, [string]$What) { if ($After.Config -ne $Before.Config) { Fail "$What changed $config" } - if ($After.Models -ne $Before.Models) { Fail "$What changed the model files from [$($Before.Models)] to [$($After.Models)]" } + if ($After.Models -ne $Before.Models) { + $running = (Get-Started | ForEach-Object { "$($_.ProcessName) (pid $($_.Id))" }) -join ', ' + Fail "$What changed the model files from [$($Before.Models)] to [$($After.Models)]; running: [$running]" + } if ($After.Components -ne $Before.Components) { Fail "$What changed the persisted selection from [$($Before.Components)] to [$($After.Components)]" } } @@ -192,6 +260,7 @@ function Test-Selection($Want) { # nothing into the profile, and the Speech to Text gate records the # choice for the Gateway's first start. Test-Presence 'Workshop' $Want.Workshop $workshopExe + Test-Presence "Workshop's std Plugin" $Want.Workshop $stdPlugin Test-Presence 'Gateway' $Want.Gateway $gatewayExe if (Test-Path -LiteralPath $config) { Fail "the install must leave the Gateway config to the Gateway's first start, but $config exists" } $downloaded = @(Get-ModelFiles) @@ -209,6 +278,19 @@ function Test-Selection($Want) { if ($output -ne $check.Expected) { Fail "$($check.Exe) --version printed '$output', required '$($check.Expected)'" } } + # Workshop loads the std Plugin it installed, which holds the file tools. + # The pipe matters: Workshop is a windowed program, and PowerShell waits + # for one, and reads its output, only when that output is piped. + if ($Want.Workshop) { + $stderr = Join-Path $logs 'plugins.stderr.log' + $listed = (& $workshopExe --plugins 2>$stderr | Out-String).Trim() + if ($listed -notmatch '(?m)^std \d') { + $reason = if (Test-Path -LiteralPath $stderr) { (Get-Content -LiteralPath $stderr -Raw) } else { '' } + Fail "$workshopExe --plugins must list the std Plugin; it printed '$listed' and exited $LASTEXITCODE. stderr: $reason" + } + Write-Host "cell: Workshop loads its std Plugin ($listed)" + } + # 4. Workshop serves its page, attached to the installed Gateway when # there is one and to the fixture endpoint otherwise. $origin = $null @@ -350,7 +432,8 @@ try { } Write-Host "cell: $Selection passed" } finally { - Stop-Started + # A failure to stop here must not hide the failure that came first. + try { Stop-Started } catch { Write-Host $_.Exception.Message } $profileLogs = Join-Path $profileDir 'logs' if (Test-Path -LiteralPath $profileLogs) { Copy-Item -LiteralPath $profileLogs -Destination (Join-Path $logs 'runvil-logs') -Recurse -Force -ErrorAction SilentlyContinue diff --git a/.github/installers/cell.sh b/.github/installers/cell.sh index 3cc13bfce..05d6482ea 100644 --- a/.github/installers/cell.sh +++ b/.github/installers/cell.sh @@ -56,6 +56,9 @@ case "$system" in root=${CELL_ROOT:-/Applications/Runvil} workshop_item="$root/Runvil.app" workshop_exe="$workshop_item/Contents/MacOS/runvil-workshop" + # The std Plugin's library rides inside Workshop's bundle, so the + # updater, which replaces the bundle, replaces it too. + std_plugin="$workshop_item/Contents/Resources/plugins/libplugin_std.dylib" gateway_item="$root/Runvil Gateway.app" gateway_exe="$gateway_item/Contents/MacOS/runvil-gateway" ;; @@ -63,6 +66,9 @@ case "$system" in root=${CELL_ROOT:-$HOME/Runvil} workshop_item="$root/Runvil.AppImage" workshop_exe=$workshop_item + # The std Plugin's library rides inside the AppImage; step 6 looks + # for it there. + std_plugin="" gateway_item="$root/runvil-gateway" gateway_exe=$gateway_item ;; @@ -174,6 +180,10 @@ check_presence() { fi } check_presence Workshop "$want_workshop" "$workshop_item" +# The std Plugin's library installs with Workshop, inside its bundle, and +# nothing installs a Plugin directory beside it. +[ -z "$std_plugin" ] || check_presence "Workshop's std Plugin" "$want_workshop" "$std_plugin" +check_presence "a Plugin directory beside Workshop" 0 "$root/plugins" check_presence Gateway "$want_gateway" "$gateway_item" if [ "$system" = Linux ]; then check_presence "Workshop's desktop entry" "$want_workshop" "$applications/com.runvil.workshop.desktop" @@ -206,6 +216,21 @@ check_version() { [ "$want_workshop" = 0 ] || check_version "$workshop_exe" "runvil-workshop $version" [ "$want_gateway" = 0 ] || check_version "$gateway_exe" "runvil-gateway $gateway_version" +# Workshop loads the std Plugin it ships with, which holds the file tools. +check_plugins() { + local output + if ! output=$("$workshop_exe" --plugins 2>"$logs/plugins.stderr.log"); then + tail -n 20 "$logs/plugins.stderr.log" >&2 + fail "$workshop_exe --plugins failed; full output in $logs/plugins.stderr.log" + fi + if ! echo "$output" | grep -Eq '^std [0-9]'; then + tail -n 20 "$logs/plugins.stderr.log" >&2 + fail "$workshop_exe --plugins must list the std Plugin; it printed '$output'" + fi + echo "cell: Workshop loads its std Plugin ($output)" +} +[ "$want_workshop" = 0 ] || check_plugins + # 4. Workshop serves its page, attached to the installed Gateway when there # is one and to the fixture endpoint otherwise. gateway_origin="" @@ -336,8 +361,13 @@ if [ "$system" = Linux ] && [ "$want_workshop" = 1 ]; then done grep -rqs GST_PLUGIN_SYSTEM_PATH_1_0 "$extracted/squashfs-root/apprun-hooks" || fail "no AppImage AppRun hook sets GST_PLUGIN_SYSTEM_PATH_1_0" + # Workshop looks for its Plugin directory from usr/bin, where it runs. + [ -f "$extracted/squashfs-root/usr/bin/runvil-workshop" ] || + fail "the AppImage lacks usr/bin/runvil-workshop, which the Plugin directory lookup starts from" + [ -f "$extracted/squashfs-root/usr/lib/runvil/plugins/libplugin_std.so" ] || + fail "the AppImage lacks usr/lib/runvil/plugins/libplugin_std.so" rm -rf "$extracted" - echo "cell: the AppImage carries its GStreamer audio plugins" + echo "cell: the AppImage carries its GStreamer audio plugins and the std Plugin" fi echo "cell: $selection passed" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fde71004..78ab368ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,7 +177,10 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + # A mirror that does not answer makes apt-get wait for a long time, so + # the step fails after 20 minutes. It normally takes under one. - name: Install Tauri system packages + timeout-minutes: 20 run: | sudo apt-get update sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev libssl-dev librsvg2-dev @@ -338,7 +341,10 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + # A mirror that does not answer makes apt-get wait for a long time, so + # the step fails after 20 minutes. It normally takes under one. - name: Install Tauri system packages + timeout-minutes: 20 run: | sudo apt-get update sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev libssl-dev librsvg2-dev diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml index ee875a491..ed0262edb 100644 --- a/.github/workflows/installers.yml +++ b/.github/workflows/installers.yml @@ -36,6 +36,7 @@ on: - crates/workshop/desktop/*.plist - crates/workshop/desktop/tauri*.conf.json - crates/workshop/desktop/icons/** + - crates/workshop/plugin-std/** - crates/workshop/package.json - crates/workshop/package-lock.json - crates/workshop/gateway-discovery/src/** @@ -259,7 +260,10 @@ jobs: GH_TOKEN: ${{ github.token }} run: node tools/fetch-gateway-sidecar.mjs --target ${{ matrix.triple }} + # A mirror that does not answer makes apt-get wait for a long time, so + # the step fails after 20 minutes. It normally takes under one. - name: Install the Linux build packages + timeout-minutes: 20 if: startsWith(matrix.platform, 'linux') run: | sudo apt-get update @@ -405,7 +409,10 @@ jobs: # (https://github.com/AppImageCommunity/pkg2appimage/blob/master/excludelist), # and Workshop opens GLES at run time; every GTK desktop has all five. # The runner images lack EGL, and a bare Ubuntu image the others. + # A mirror that does not answer makes apt-get wait for a long time, so + # the step fails after 20 minutes. It normally takes under one. - name: Install the Linux test packages + timeout-minutes: 20 if: runner.os == 'Linux' run: | set -euo pipefail diff --git a/Cargo.lock b/Cargo.lock index 5bed32663..c3e25dd08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2271,6 +2271,7 @@ dependencies = [ "libloading 0.8.9", "runvil-plugin", "runvil-plugin-sys", + "serde_json", ] [[package]] @@ -2294,7 +2295,9 @@ dependencies = [ "harness-plugin-loader", "libc", "libloading 0.8.9", + "runvil", "runvil-plugin", + "tokio", ] [[package]] @@ -4081,10 +4084,11 @@ dependencies = [ [[package]] name = "plugin-std" -version = "0.3.0" +version = "0.1.0" +source = "git+https://github.com/cppalliance/runvil-plugin-std?rev=782ad23e5dfbe6fbfa5be7145fa73a9ca60ce8a8#782ad23e5dfbe6fbfa5be7145fa73a9ca60ce8a8" dependencies = [ "regex", - "runvil-plugin", + "runvil-plugin-sdk", "serde", "serde_json", ] @@ -4818,10 +4822,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "runvil-plugin-sdk" +version = "0.4.0" +source = "git+https://github.com/cppalliance/runvil-plugin-api?rev=03a9fa37d2fe690d42ee0aa5c7a8289173e8e0f2#03a9fa37d2fe690d42ee0aa5c7a8289173e8e0f2" +dependencies = [ + "runvil-plugin-sys", +] + [[package]] name = "runvil-plugin-sys" -version = "0.2.0" -source = "git+https://github.com/cppalliance/runvil-plugin-api?rev=63a2403761b925b1f52bcf361c97b4fb04c04d74#63a2403761b925b1f52bcf361c97b4fb04c04d74" +version = "0.4.0" +source = "git+https://github.com/cppalliance/runvil-plugin-api?rev=03a9fa37d2fe690d42ee0aa5c7a8289173e8e0f2#03a9fa37d2fe690d42ee0aa5c7a8289173e8e0f2" [[package]] name = "runvil-prompt" @@ -8034,6 +8046,14 @@ dependencies = [ "workshop-support", ] +[[package]] +name = "workshop-plugin-std" +version = "0.3.0" +dependencies = [ + "plugin-std", + "runvil-plugin-sdk", +] + [[package]] name = "workshop-protocol" version = "0.0.0" @@ -8075,7 +8095,6 @@ dependencies = [ "futures-util", "harness-gateway-client", "plugin-mcp", - "plugin-std", "plugin-web", "reqwest", "runvil", @@ -8093,6 +8112,7 @@ dependencies = [ "workshop-agents", "workshop-gateway", "workshop-menu", + "workshop-plugin-std", "workshop-protocol", "workshop-registry", "workshop-run-log", diff --git a/Cargo.toml b/Cargo.toml index a02644680..26a1b1d8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/*", "crates/runvil-internal/types", "crates/runvil-internal/runtime", "crates/runvil-internal/lua", "crates/runvil-internal/prompt", "crates/runvil-internal/vfs", "crates/runvil-internal/model-client", "crates/workshop/desktop", "crates/workshop/server", "crates/workshop/server-api", "crates/workshop/gateway", "crates/workshop/gateway-discovery", "crates/workshop/menu", "crates/workshop/protocol", "crates/workshop/registry", "crates/workshop/status", "crates/workshop/support", "crates/workshop/user-state", "crates/workshop/workspace", "crates/workshop/run-log", "crates/workshop/agents", "crates/harness-internal/runner", "crates/harness-internal/plugin-loader", "crates/harness-internal/test-plugin"] +members = ["crates/*", "crates/runvil-internal/types", "crates/runvil-internal/runtime", "crates/runvil-internal/lua", "crates/runvil-internal/prompt", "crates/runvil-internal/vfs", "crates/runvil-internal/model-client", "crates/workshop/desktop", "crates/workshop/server", "crates/workshop/server-api", "crates/workshop/gateway", "crates/workshop/gateway-discovery", "crates/workshop/menu", "crates/workshop/protocol", "crates/workshop/registry", "crates/workshop/status", "crates/workshop/support", "crates/workshop/user-state", "crates/workshop/workspace", "crates/workshop/run-log", "crates/workshop/agents", "crates/workshop/plugin-std", "crates/harness-internal/runner", "crates/harness-internal/plugin-loader", "crates/harness-internal/test-plugin"] # crates/runvil-internal, crates/workshop, and crates/harness-internal # are manifestless containers holding their families' private crates. # Cargo prunes excluded subtrees from member globs, so a "crates/workshop/*" @@ -28,9 +28,18 @@ bytes = "1" runvil-engine = { path = "crates/runvil-engine" } runvil-runtime = { path = "crates/runvil-internal/runtime" } runvil-plugin = { path = "crates/runvil-plugin" } -# The C Plugin contract, version 0.2, from its own repository, pinned by +# The C Plugin contract, version 0.4, from its own repository, pinned by # commit. `version` beside `git` keeps cargo-deny's wildcard check quiet. -runvil-plugin-sys = { version = "0.2.0", git = "https://github.com/cppalliance/runvil-plugin-api", rev = "63a2403761b925b1f52bcf361c97b4fb04c04d74" } +runvil-plugin-sys = { version = "0.4.0", git = "https://github.com/cppalliance/runvil-plugin-api", rev = "03a9fa37d2fe690d42ee0aa5c7a8289173e8e0f2" } +# The safe layer over that contract, at the same commit. Only the library +# Plugin crate under `crates/workshop/` names it, for `export_plugin!`. +runvil-plugin-sdk = { version = "0.4.0", git = "https://github.com/cppalliance/runvil-plugin-api", rev = "03a9fa37d2fe690d42ee0aa5c7a8289173e8e0f2" } +# The std Plugin, from its own repository, pinned by commit, which must be +# built against the contract commit above. default-features off drops its +# own export of `runvil_plugin`, which `workshop-plugin-std` makes instead. +# The key is `std-plugin` because that crate's own library is named +# `plugin_std`, the name of the file Workshop ships. +std-plugin = { package = "plugin-std", version = "0.1.0", git = "https://github.com/cppalliance/runvil-plugin-std", rev = "782ad23e5dfbe6fbfa5be7145fa73a9ca60ce8a8", default-features = false } runvil-types = { path = "crates/runvil-internal/types" } workshop-gateway-discovery = { path = "crates/workshop/gateway-discovery" } shared-error-source = { path = "crates/shared-error-source" } @@ -39,7 +48,6 @@ harness-gateway-client = { path = "crates/harness-gateway-client" } harness-runner = { path = "crates/harness-internal/runner" } harness-plugin-loader = { path = "crates/harness-internal/plugin-loader" } plugin-web = { path = "crates/plugin-web" } -plugin-std = { path = "crates/plugin-std" } plugin-mcp = { path = "crates/plugin-mcp" } runvil-lua = { path = "crates/runvil-internal/lua" } runvil-model-client = { path = "crates/runvil-internal/model-client" } @@ -57,6 +65,7 @@ workshop-workspace = { path = "crates/workshop/workspace" } workshop-run-log = { path = "crates/workshop/run-log" } workshop-agents = { path = "crates/workshop/agents" } workshop-user-state = { path = "crates/workshop/user-state" } +workshop-plugin-std = { path = "crates/workshop/plugin-std" } # default-features off: both users (the parser and build-xtask's doctest # scan) read only the core event stream, never the `html` renderer or the # `getopts` binary. diff --git a/crates/build-workshop/README.md b/crates/build-workshop/README.md index 134f2f1f4..8c6922bb0 100644 --- a/crates/build-workshop/README.md +++ b/crates/build-workshop/README.md @@ -4,7 +4,7 @@ This crate is `cargo workshop`, the one build path for Runvil's desktop products ## Modes -- `cargo workshop [--release] [--target ] [--gateway-binary ]` builds Workshop with Cargo. For a Windows target the gateway is Workshop's sidecar; elsewhere the build copies it beside Workshop in the target directory, where a development Workshop finds it. +- `cargo workshop [--release] [--target ] [--gateway-binary ]` builds Workshop with Cargo. For a Windows target the gateway is Workshop's sidecar; elsewhere the build copies it beside Workshop in the target directory, where a development Workshop finds it. Every build also copies the std Plugin's library into `plugins` beside Workshop, where a development Workshop loads it. - `cargo workshop installer [--target ] [--sign] [--gateway-binary ]` builds one platform's installer in release. - `cargo workshop manifest --assets --base-url [--public-key ]` checks that a directory holds exactly one release's files for every platform, verifies each updater signature, and writes `latest.json` and `SHA256SUMS` beside them. @@ -20,6 +20,10 @@ node tools/fetch-gateway-sidecar.mjs --target The script downloads the release asset, checks its SHA-256 against the pin, and stages the executable in `crates/workshop/desktop/binaries/`, where every later build reuses it. `--gateway-binary `, or the `RUNVIL_GATEWAY_SIDECAR` environment variable, names another gateway instead; for a Windows target, remove the staged one first, because a named gateway never replaces it. The installer build runs the gateway's `--version` and refuses one that is not the pinned version. +## The std Plugin + +The file tools live in the std Plugin, a dynamic library whose code is in [cppalliance/runvil-plugin-std](https://github.com/cppalliance/runvil-plugin-std), pinned by commit in the root `Cargo.toml`. Nothing here builds it by name: the Workshop server depends on `workshop-plugin-std`, so Cargo writes `plugin_std.dll`, `libplugin_std.so`, or `libplugin_std.dylib` to `deps` in the profile's directory whenever it builds Workshop. Each mode takes the library from there. A build copies it into `plugins` beside Workshop in the target directory. A macOS or Linux installer has Tauri copy it into Workshop's bundle, at `Runvil.app/Contents/Resources/plugins` or at `usr/lib/runvil/plugins` in the AppImage, so the updater, which replaces only that bundle, replaces the library with it. The installer build fails when the bundle Tauri built does not hold the library. A Workshop run with plain `cargo run -p workshop` has no Plugin directory, so its agents have no file tools; run `cargo workshop` once to place the library. Plain Cargo builds do not refresh the placed copy, so run `cargo workshop` again after the pin or the contract version changes. + ## Building an installer Every platform needs Node 22, the staged gateway above, and the UI install, which brings the pinned Tauri CLI: @@ -31,10 +35,10 @@ npm ci --prefix crates/workshop The output lands in `target/installer//`: - `publish/`: the installer, and under `--sign` the updater files and their `.sig` signatures. Windows gets `Runvil__x64-setup.exe`, macOS `Runvil__-setup.dmg`, and Linux `Runvil__-setup.run`. -- `payload/` (macOS and Linux): the installed items, `Runvil.app` and `Runvil Gateway.app`, or `Runvil.AppImage` and `runvil-gateway`. -- `ifw/` (macOS and Linux): the Qt IFW definition filled in from `ifw/` in this crate, with the payload copied into its packages. +- `payload/` (macOS and Linux): the installed items, `Runvil.app` and `Runvil Gateway.app`, or `Runvil.AppImage` and `runvil-gateway`. The std Plugin's library is inside Workshop's item. +- `ifw/` (macOS and Linux): the Qt IFW definition filled in from `ifw/` in this crate, with the payload copied into its packages. The Plugin directory goes in Workshop's package. -Windows needs nothing more: Tauri builds the NSIS setup from `crates/workshop/desktop/installer.nsi`, with the gateway inside. +Windows needs nothing more: Tauri builds the NSIS setup from `crates/workshop/desktop/installer.nsi`, with the gateway inside. That template takes the std Plugin's library from `deps` beside the built executable and installs it as `plugins\plugin_std.dll`. Linux also needs Tauri's build packages and the libraries the Qt IFW tools load even without a display, as `installers.yml` installs them on Ubuntu: diff --git a/crates/build-workshop/src/main.rs b/crates/build-workshop/src/main.rs index 033b8d638..c33d5d7f1 100644 --- a/crates/build-workshop/src/main.rs +++ b/crates/build-workshop/src/main.rs @@ -2,7 +2,11 @@ //! release. For Windows targets the Gateway is Tauri's sidecar inside //! Workshop's bundle; macOS and Linux payloads carry it beside Workshop, and //! Qt IFW packages them into one installer. The Gateway is its own product -//! in `cppalliance/runvil-gateway`, so nothing here compiles it. The +//! in `cppalliance/runvil-gateway`, so nothing here compiles it. Every +//! build and every installer carries the std Plugin's library in +//! Workshop's Plugin directory: `plugins` beside Workshop in a build and +//! on Windows, and inside Workshop's bundle on macOS and Linux +//! (`plugin`). The //! `manifest` mode verifies every platform's published files as one release //! and writes its `latest.json` and `SHA256SUMS`. @@ -23,6 +27,8 @@ mod installer; mod manifest; #[path = "main/pipeline.rs"] mod pipeline; +#[path = "main/plugin.rs"] +mod plugin; #[path = "main/runner.rs"] mod runner; #[path = "main/sidecar.rs"] diff --git a/crates/build-workshop/src/main/installer.rs b/crates/build-workshop/src/main/installer.rs index c0111bd35..9b8e593a3 100644 --- a/crates/build-workshop/src/main/installer.rs +++ b/crates/build-workshop/src/main/installer.rs @@ -14,7 +14,9 @@ use std::path::{Path, PathBuf}; use super::args::InstallerRequest; use super::pipeline::{resolve_target, run_checked, with_staged_sidecar}; use super::sidecar::{self, GatewaySource}; -use super::{BuildEnvironment, BuildError, CommandRunner, CommandSpec, OutputMode, StepError}; +use super::{ + BuildEnvironment, BuildError, CommandRunner, CommandSpec, OutputMode, StepError, plugin, +}; // This file is loaded through a path attribute, so its children need one // too to resolve under `installer/`. @@ -133,10 +135,16 @@ pub(super) fn build_installer( }; run_checked(runner, &node_check_command(environment), NODE_CHECK)?; let release = environment.target_root.join(&target).join("release"); + let bundle_directory = release.join("bundle").join(platform.bundle_directory()); // A bundle left by an earlier build, possibly at another version, would // make the collection ambiguous. - remove_directory(&release.join("bundle").join(platform.bundle_directory()))?; + remove_directory(&bundle_directory)?; check_gateway_version(runner, environment, &gateway)?; + // The Windows setup takes the std Plugin's library from the build + // itself. On macOS and Linux Tauri copies it into Workshop's bundle, + // so the updater replaces it with Workshop. + let library = plugin::built_library(&release, &target); + let plugin_config = plugin::bundle_config(platform.system, &library).map_err(failure)?; with_staged_sidecar( environment, &target, @@ -144,11 +152,23 @@ pub(super) fn build_installer( "Workshop bundle", &source, |runner| { - let mut bundle = tauri_build_command(environment, &target, platform, request.sign); + let mut bundle = tauri_build_command( + environment, + &target, + platform, + request.sign, + plugin_config.as_deref(), + ); bundle.envs.extend(source.child_envs()); run_checked(runner, &bundle, "Workshop bundle").map(|_| ()) }, )?; + plugin::check_bundled( + platform.system, + &bundle_directory, + plugin::library_name(&target), + ) + .map_err(failure)?; let output = environment.target_root.join("installer").join(&target); collect::collect( &environment.workspace_root, @@ -299,11 +319,15 @@ fn check_gateway_version( } } +/// The Tauri build of Workshop's bundle for `target`. `plugin_config` is +/// the configuration that copies the std Plugin's library into the +/// bundle, when the platform has one. fn tauri_build_command( environment: &BuildEnvironment, target: &str, platform: Platform, sign: bool, + plugin_config: Option<&str>, ) -> CommandSpec { let mut args = vec![environment.tauri_cli.clone().into_os_string()]; args.extend( @@ -320,6 +344,9 @@ fn tauri_build_command( if !sign { args.extend(["--config", UNSIGNED_CONFIG].map(OsString::from)); } + if let Some(config) = plugin_config { + args.extend(["--config", config].map(OsString::from)); + } CommandSpec { program: environment.node.clone(), args, diff --git a/crates/build-workshop/src/main/installer/collect.rs b/crates/build-workshop/src/main/installer/collect.rs index 33cdc6657..c79a4f60c 100644 --- a/crates/build-workshop/src/main/installer/collect.rs +++ b/crates/build-workshop/src/main/installer/collect.rs @@ -165,23 +165,18 @@ fn move_path(source: &Path, destination: &Path) -> Result<(), String> { }) } -/// Copies a binary the installer packages install as a program, with mode -/// `0755` whatever the source's mode. +/// Copies a binary the installer packages install as a program. On Unix +/// the copy gets mode `0755` whatever the source's mode; Windows has no +/// mode to set. pub(crate) fn copy_executable(source: &Path, destination: &Path) -> Result<(), String> { copy_file(source, destination)?; - set_executable(destination) - .map_err(|error| format!("cannot make {} executable: {error}", destination.display())) -} - -#[cfg(unix)] -fn set_executable(path: &Path) -> io::Result<()> { - use std::os::unix::fs::PermissionsExt as _; - fs::set_permissions(path, fs::Permissions::from_mode(0o755)) -} - -/// Only macOS and Linux payloads hold the Gateway outside Workshop. -#[cfg(not(unix))] -fn set_executable(_path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(destination, fs::Permissions::from_mode(0o755)).map_err(|error| { + format!("cannot make {} executable: {error}", destination.display()) + })?; + } Ok(()) } diff --git a/crates/build-workshop/src/main/pipeline.rs b/crates/build-workshop/src/main/pipeline.rs index 635ab7fce..e2b74729a 100644 --- a/crates/build-workshop/src/main/pipeline.rs +++ b/crates/build-workshop/src/main/pipeline.rs @@ -10,7 +10,7 @@ use super::installer::collect::copy_executable; use super::sidecar::{self, GatewaySource}; use super::{ BuildEnvironment, BuildError, BuildRequest, CommandRunner, CommandSpec, OutputMode, Profile, - StepError, + StepError, plugin, }; pub(super) fn build_workshop( @@ -43,17 +43,26 @@ pub(super) fn build_workshop( let mut build = cargo_build_command(environment, request, "workshop"); build.envs.extend(source.child_envs()); run_checked(runner, &build, "Workshop build")?; + let mut profile = environment.target_root.clone(); + if request.target.is_some() { + profile.push(&target); + } + profile.push(request.profile.directory()); + // Workshop loads its Plugins from `plugins` beside its own + // executable in a development tree. + plugin::place(&plugin::built_library(&profile, &target), &profile).map_err( + |reason| { + StepError::failed(format!( + "cannot place the std Plugin beside Workshop: {reason}" + )) + }, + )?; if sidecar::bundles_sidecar(&target) { return Ok(()); } // Workshop finds the Gateway beside its own executable in a // development tree, as tauri-build arranges on Windows. - let mut beside = environment.target_root.clone(); - if request.target.is_some() { - beside.push(&target); - } - beside.push(request.profile.directory()); - beside.push(sidecar::gateway_binary_name(&target)); + let beside = profile.join(sidecar::gateway_binary_name(&target)); let gateway = source.binary(&environment.workspace_root, &target); // Copying a file onto itself would truncate it. if fs::canonicalize(&gateway).ok() == fs::canonicalize(&beside).ok() && beside.exists() diff --git a/crates/build-workshop/src/main/plugin.rs b/crates/build-workshop/src/main/plugin.rs new file mode 100644 index 000000000..c7278a69a --- /dev/null +++ b/crates/build-workshop/src/main/plugin.rs @@ -0,0 +1,152 @@ +//! The std Plugin's library, which Workshop loads from the Plugin +//! directory of its installation. +//! +//! Nothing here builds the library. The Workshop server depends on +//! `workshop-plugin-std`, so Cargo writes the library to `deps` in the +//! profile's directory whenever it builds Workshop, and each mode takes +//! it from there. A build copies it into `plugins` beside Workshop in the +//! target directory. A macOS or Linux installer has Tauri copy it into +//! Workshop's bundle, so the updater, which replaces only that bundle, +//! replaces the library with it. The Windows setup takes it from `deps` +//! itself, in `crates/workshop/desktop/installer.nsi`. + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::installer::System; + +// The names Workshop's Plugin directory lookup searches +// (`crates/workshop/gateway-discovery/src/peer.rs`); a test reads that file +// and requires the same three. +/// The Plugin directory's name. +pub(crate) const PLUGIN_DIR_NAME: &str = "plugins"; +/// The directory that holds the Plugin directory inside the macOS +/// Workshop bundle, from the bundle's `Contents`. +pub(crate) const MACOS_PLUGIN_PARENT: &str = "Resources"; +/// The directory that holds the Plugin directory inside the Linux +/// AppImage, from the root of the AppDir. +pub(crate) const APPIMAGE_PLUGIN_PARENT: &str = "usr/lib/runvil"; + +/// The file name Cargo gives the std Plugin's library for `target`. +pub(crate) fn library_name(target: &str) -> &'static str { + let has = |part: &str| target.split('-').any(|word| word == part); + if has("windows") { + "plugin_std.dll" + } else if has("apple") { + "libplugin_std.dylib" + } else { + "libplugin_std.so" + } +} + +/// Where Cargo writes the library when it builds Workshop for `target` +/// into `profile_dir`. +pub(crate) fn built_library(profile_dir: &Path, target: &str) -> PathBuf { + profile_dir.join("deps").join(library_name(target)) +} + +/// Where Workshop's bundle for `system` keeps the library named `name`, +/// both with `/` between their segments: the tree Tauri leaves under the +/// bundle's own directory, and the library's path inside that tree. The +/// tree is `Contents` in the macOS bundle and the AppDir on Linux. `None` +/// for Windows, which has no bundle. +fn bundled(system: System, name: &str) -> Option<(&'static str, String)> { + let (tree, parent) = match system { + System::Windows => return None, + System::MacOs => ("Runvil.app/Contents", MACOS_PLUGIN_PARENT), + System::Linux => ("Runvil.AppDir", APPIMAGE_PLUGIN_PARENT), + }; + Some((tree, format!("{parent}/{PLUGIN_DIR_NAME}/{name}"))) +} + +/// The Tauri configuration, as JSON text for `--config`, that copies +/// `library` into Workshop's bundle for `system`. `Ok(None)` for +/// Windows, whose setup installs the library itself. +/// +/// The bundler copies these files after Cargo has built Workshop, so the +/// library exists by then. Tauri's `bundle.resources` would not do: the +/// desktop crate's build script checks that each resource exists, and it +/// runs before Cargo has built the library. +pub(crate) fn bundle_config(system: System, library: &Path) -> Result, String> { + let name = library + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| format!("{} names no file", library.display()))?; + let Some((_, inside)) = bundled(system, name) else { + return Ok(None); + }; + let source = library + .to_str() + .ok_or_else(|| format!("{} is not UTF-8", library.display()))?; + // Tauri takes a macOS path from `Contents`, and an AppImage path from + // the root of the AppDir, written as an absolute path. + let files = if system == System::MacOs { + serde_json::json!({ "macOS": { "files": { inside: source } } }) + } else { + serde_json::json!({ "linux": { "appimage": { "files": { format!("/{inside}"): source } } } }) + }; + Ok(Some(serde_json::json!({ "bundle": files }).to_string())) +} + +/// Checks that the bundle Tauri built for `system` holds the library +/// named `name`. `bundle` is the bundle's own directory under +/// `release/bundle`. +/// +/// # Errors +/// Returns the path that is missing. A Tauri CLI that ignored the +/// configuration of [`bundle_config`] would otherwise ship a Workshop +/// with no file tools. +pub(crate) fn check_bundled(system: System, bundle: &Path, name: &str) -> Result<(), String> { + let Some((tree, inside)) = bundled(system, name) else { + return Ok(()); + }; + let library = tree + .split('/') + .chain(inside.split('/')) + .fold(bundle.to_path_buf(), |path, segment| path.join(segment)); + if library.is_file() { + return Ok(()); + } + Err(format!( + "the Workshop bundle lacks the std Plugin's library: {} is missing after the Tauri build", + library.display() + )) +} + +/// Copies `library` into the Plugin directory in `beside`, which it makes +/// when it is missing. +pub(crate) fn place(library: &Path, beside: &Path) -> Result<(), String> { + let name = library + .file_name() + .ok_or_else(|| format!("{} names no file", library.display()))?; + if !library.is_file() { + return Err(format!( + "{} is missing; a Workshop build writes it, because the server depends on \ + workshop-plugin-std", + library.display() + )); + } + let directory = beside.join(PLUGIN_DIR_NAME); + fs::create_dir_all(&directory) + .map_err(|error| format!("cannot create {}: {error}", directory.display()))?; + let placed = directory.join(name); + // A Workshop from an earlier run may still have the library loaded, + // and writing into a loaded library's file crashes that process on + // Linux. A rename replaces the name and leaves that file alone. + let fresh = placed.with_extension("new"); + let _ = fs::remove_file(&fresh); + fs::copy(library, &fresh).map_err(|error| { + format!( + "cannot copy {} to {}: {error}", + library.display(), + fresh.display() + ) + })?; + fs::rename(&fresh, &placed).map_err(|error| { + format!( + "cannot move {} to {}: {error}", + fresh.display(), + placed.display() + ) + }) +} diff --git a/crates/build-workshop/src/main/tests.rs b/crates/build-workshop/src/main/tests.rs index 81a73f6f1..c655856f0 100644 --- a/crates/build-workshop/src/main/tests.rs +++ b/crates/build-workshop/src/main/tests.rs @@ -23,6 +23,8 @@ mod gateway_tests; mod ifw_tests; #[path = "tests/installer.rs"] mod installer_tests; +#[path = "tests/plugin.rs"] +mod plugin_tests; #[path = "tests/sidecar.rs"] mod sidecar_tests; #[path = "tests/stage.rs"] @@ -165,6 +167,13 @@ fn repository_root() -> PathBuf { .to_path_buf() } +/// What the std Plugin's library holds in a test, as Cargo built it in +/// `profile` for the named `target`, or for the host when no target is +/// named. Each build's differs, so a test shows which one was placed. +fn plugin_bytes(target: Option<&str>, profile: &str) -> Vec { + format!("plugin for {} in {profile}", target.unwrap_or("the host")).into_bytes() +} + fn write_file(path: &Path, contents: &[u8]) { std::fs::create_dir_all(path.parent().expect("parent")).expect("parent directory"); std::fs::write(path, contents).expect("write file"); @@ -191,7 +200,37 @@ impl TestEnvironment { self.stage_gateway_kind(target, "stub") } + /// Writes the std Plugin's library where Cargo leaves it whenever it + /// builds Workshop for `target`, in either profile, with the target + /// named or left to the host. Each of the four holds its own + /// [`plugin_bytes`]. No fake build writes a file of its own, and + /// every test that builds Workshop names its Gateway first, so the + /// Gateway helpers call this. + fn leave_plugin(&self, target: &str) { + let root = &self.environment.target_root; + for (base, named) in [(root.clone(), None), (root.join(target), Some(target))] { + for profile in ["debug", "release"] { + write_file( + &plugin::built_library(&base.join(profile), target), + &plugin_bytes(named, profile), + ); + } + } + } + + /// Removes what [`leave_plugin`](Self::leave_plugin) wrote, as a + /// build that never compiled the library leaves things. + fn lose_plugin(&self, target: &str) { + let root = &self.environment.target_root; + for base in [root.clone(), root.join(target)] { + for profile in ["debug", "release"] { + let _ = std::fs::remove_file(plugin::built_library(&base.join(profile), target)); + } + } + } + fn stage_gateway_kind(&self, target: &str, kind: &str) -> PathBuf { + self.leave_plugin(target); let slot = self.sidecar(target); write_file(&slot, b"gateway"); let mut marker = slot.clone().into_os_string(); @@ -206,6 +245,7 @@ impl TestEnvironment { /// A Gateway binary outside the slot, as `--gateway-binary` or /// `RUNVIL_GATEWAY_SIDECAR` names one. fn given_gateway(&self, target: &str) -> PathBuf { + self.leave_plugin(target); let path = self .temp .path() diff --git a/crates/build-workshop/src/main/tests/bundle.rs b/crates/build-workshop/src/main/tests/bundle.rs index f0f40836c..032fa72a1 100644 --- a/crates/build-workshop/src/main/tests/bundle.rs +++ b/crates/build-workshop/src/main/tests/bundle.rs @@ -5,8 +5,8 @@ use serde_json::Value; use super::ifw_tests::{ifw_built, ifw_commands, ifw_found, installer_path}; use super::installer_tests::{ - bundle, bundled, collect_signature, expected_commands, node_found, output, request, - version_printed, + bundle, bundled, bundled_plugin, collect_signature, expected_commands, node_found, output, + request, version_printed, }; use super::*; use crate::installer::Platform; @@ -200,6 +200,7 @@ fn macos_signed_collects_the_payload_and_publishes_workshop_s_updater() { .join("Contents") .join("MacOS") .join("runvil-workshop"), + bundled_plugin(environment, target), collect_signature(&updater), updater, ], @@ -257,7 +258,10 @@ fn macos_unsigned_publishes_only_the_dmg_around_the_pinned_gateway() { version_printed(), bundled( staged, - vec![app.join("Contents").join("MacOS").join("runvil-workshop")], + vec![ + app.join("Contents").join("MacOS").join("runvil-workshop"), + bundled_plugin(environment, target), + ], success(""), ), ]; @@ -289,6 +293,18 @@ fn macos_unsigned_publishes_only_the_dmg_around_the_pinned_gateway() { "{plist}" ); assert!(!payload.join("runvil-gateway").exists()); + // The std Plugin's library sits inside Workshop's bundle, where the + // updater replaces it with Workshop, and nowhere beside it. + assert!( + payload + .join("Runvil.app") + .join("Contents") + .join("Resources") + .join("plugins") + .join("libplugin_std.dylib") + .is_file() + ); + assert!(!payload.join("plugins").exists()); let installer = installer_path(environment, target); assert_eq!( installer.file_name().expect("name"), diff --git a/crates/build-workshop/src/main/tests/collect.rs b/crates/build-workshop/src/main/tests/collect.rs index d73463bdb..c5bd32e0c 100644 --- a/crates/build-workshop/src/main/tests/collect.rs +++ b/crates/build-workshop/src/main/tests/collect.rs @@ -277,6 +277,12 @@ fn the_installed_names_match_workshop_s_gateway_lookup() { ("WORKSHOP_BUNDLE_NAME", collect::WORKSHOP_BUNDLE_NAME), ("GATEWAY_BUNDLE_NAME", collect::GATEWAY_BUNDLE_NAME), ("WORKSHOP_APPIMAGE_NAME", collect::WORKSHOP_APPIMAGE_NAME), + ("PLUGIN_DIR_NAME", crate::plugin::PLUGIN_DIR_NAME), + ("MACOS_PLUGIN_PARENT", crate::plugin::MACOS_PLUGIN_PARENT), + ( + "APPIMAGE_PLUGIN_PARENT", + crate::plugin::APPIMAGE_PLUGIN_PARENT, + ), ] { assert_eq!(declared(&text, name), ours, "{name} in {}", peer.display()); } diff --git a/crates/build-workshop/src/main/tests/ifw.rs b/crates/build-workshop/src/main/tests/ifw.rs index 85a9b5424..d815da4dc 100644 --- a/crates/build-workshop/src/main/tests/ifw.rs +++ b/crates/build-workshop/src/main/tests/ifw.rs @@ -1,7 +1,9 @@ //! Unit tests for locating Qt IFW, its version check, and the installer //! commands, with the helpers the installer sequences share. -use super::installer_tests::{bundle, bundled, node_found, output, request, version_printed}; +use super::installer_tests::{ + bundle, bundled, bundled_plugin, node_found, output, request, version_printed, +}; use super::*; use crate::installer::Platform; use crate::installer::ifw::{self, IFW_VERSION, Ifw, Sources}; @@ -355,7 +357,7 @@ fn bundled_responses(test_environment: &TestEnvironment, target: &str) -> Vec PathBuf { + if target.contains("apple") { + bundle(environment, target, "macos") + .join("Runvil.app") + .join("Contents") + .join("Resources") + .join("plugins") + .join("libplugin_std.dylib") + } else { + bundle(environment, target, "appimage") + .join("Runvil.AppDir") + .join("usr") + .join("lib") + .join("runvil") + .join("plugins") + .join("libplugin_std.so") + } +} + +/// The `--config` text that has Tauri copy the library of `target`'s +/// release build into Workshop's bundle, or `None` for Windows. +fn plugin_config(environment: &BuildEnvironment, target: &str) -> Option { + let deps = release(environment, target).join("deps"); + let config = if target.contains("apple") { + let library = deps.join("libplugin_std.dylib"); + serde_json::json!({ + "bundle": { "macOS": { "files": { + "Resources/plugins/libplugin_std.dylib": library + } } } + }) + } else if target.contains("linux") { + let library = deps.join("libplugin_std.so"); + serde_json::json!({ + "bundle": { "linux": { "appimage": { "files": { + "/usr/lib/runvil/plugins/libplugin_std.so": library + } } } } + }) + } else { + return None; + }; + Some(config.to_string()) +} + pub(super) fn expected_commands( environment: &BuildEnvironment, target: &str, bundle: &str, sign: bool, ) -> Vec { + let plugin_config = plugin_config(environment, target); let mut tauri = vec![ environment.tauri_cli.to_str().expect("UTF-8 CLI"), "build", @@ -70,6 +117,11 @@ pub(super) fn expected_commands( if !sign { tauri.extend(["--config", "tauri.unsigned.conf.json"]); } + // The library of the installer's own release build goes into the + // bundle, so the updater replaces it with Workshop. + if let Some(config) = &plugin_config { + tauri.extend(["--config", config.as_str()]); + } // macOS and Linux check Qt IFW once the target is known. let ifw = (!target.ends_with("windows-msvc")).then(|| ifw_version_command(environment)); ifw.into_iter() @@ -190,6 +242,7 @@ fn linux_unsigned_from_the_host_publishes_only_the_installer() { vec![ appimage.join(format!("Runvil_{VERSION}_amd64.AppImage")), appimage.join("Runvil.AppDir").join("AppRun"), + bundled_plugin(environment, target), ], success(""), ), @@ -228,6 +281,18 @@ fn linux_unsigned_from_the_host_publishes_only_the_installer() { std::fs::read(output_root.join("payload").join("runvil-gateway")).expect("payload"), b"gateway" ); + // The std Plugin's library rides inside the AppImage, so nothing + // beside it carries a Plugin directory. + assert!(!output_root.join("payload").join("plugins").exists()); + assert!( + !output_root + .join("ifw") + .join("packages") + .join("com.runvil.workshop") + .join("data") + .join("plugins") + .exists() + ); let installer = installer_path(environment, target); assert_eq!( installer.file_name().expect("name"), @@ -270,7 +335,11 @@ fn linux_signed_publishes_the_appimage_and_its_signature_beside_the_installer() version_printed(), bundled( staged, - vec![collect_signature(&appimage), appimage], + vec![ + collect_signature(&appimage), + appimage, + bundled_plugin(environment, target), + ], success(""), ), ]; diff --git a/crates/build-workshop/src/main/tests/plugin.rs b/crates/build-workshop/src/main/tests/plugin.rs new file mode 100644 index 000000000..36380c96f --- /dev/null +++ b/crates/build-workshop/src/main/tests/plugin.rs @@ -0,0 +1,237 @@ +//! Unit tests for the std Plugin's library: the name Cargo gives it for +//! each target, where a build places it, what a build that left none +//! says, and how an installer gets it into Workshop's bundle. + +use super::ifw_tests::ifw_found; +use super::installer_tests::{bundled, node_found, request, version_printed}; +use super::*; +use crate::installer::System; + +#[test] +fn the_library_is_named_as_cargo_names_it_for_each_target() { + for (target, name) in [ + ("x86_64-pc-windows-msvc", "plugin_std.dll"), + ("aarch64-pc-windows-msvc", "plugin_std.dll"), + ("x86_64-apple-darwin", "libplugin_std.dylib"), + ("aarch64-apple-darwin", "libplugin_std.dylib"), + ("x86_64-unknown-linux-gnu", "libplugin_std.so"), + ("aarch64-unknown-linux-gnu", "libplugin_std.so"), + ] { + assert_eq!(plugin::library_name(target), name, "{target}"); + assert_eq!( + plugin::built_library(Path::new("profile"), target), + Path::new("profile").join("deps").join(name), + "{target}" + ); + } +} + +#[test] +fn a_host_build_places_the_library_in_the_plugin_directory_beside_workshop() { + let test_environment = environment(); + let environment = &test_environment.environment; + let triple = "x86_64-pc-windows-msvc"; + let staged = test_environment.stage_gateway(triple); + let mut runner = FakeRunner::with_responses(vec![ + success("cargo 1.89.0\nhost: x86_64-pc-windows-msvc\n"), + workshop_built_with_sidecar(staged, success("")), + ]); + + build_workshop( + &BuildRequest { + profile: Profile::Debug, + target: None, + gateway_binary: None, + }, + environment, + &mut runner, + ) + .expect("Workshop build"); + + let plugins = environment.target_root.join("debug").join("plugins"); + assert_eq!( + std::fs::read(plugins.join("plugin_std.dll")).expect("the placed library"), + plugin_bytes(None, "debug"), + "the library of the host's debug build, and of no other" + ); + assert_eq!( + std::fs::read_dir(&plugins).expect("plugins").count(), + 1, + "the copy leaves no other file behind" + ); +} + +#[test] +fn a_build_for_a_named_target_places_the_library_under_that_target_and_replaces_an_old_one() { + let test_environment = environment(); + let environment = &test_environment.environment; + let triple = "aarch64-unknown-linux-gnu"; + test_environment.stage_gateway(triple); + let profile = environment.target_root.join(triple).join("release"); + let placed = profile.join("plugins").join("libplugin_std.so"); + write_file(&placed, b"a library from an earlier build"); + let mut runner = + FakeRunner::with_responses(vec![workshop_built_into(profile.clone(), success(""))]); + + build_workshop( + &BuildRequest { + profile: Profile::Release, + target: Some(triple.to_owned()), + gateway_binary: None, + }, + environment, + &mut runner, + ) + .expect("Workshop build"); + + assert_eq!( + std::fs::read(&placed).expect("the placed library"), + plugin_bytes(Some(triple), "release"), + "the library of the named target's release build, and of no other" + ); + assert!( + !environment + .target_root + .join("release") + .join("plugins") + .exists(), + "nothing is placed under the host's profile" + ); +} + +#[test] +fn a_build_that_left_no_library_fails_naming_the_file_and_places_nothing() { + let test_environment = environment(); + let environment = &test_environment.environment; + let triple = "x86_64-unknown-linux-gnu"; + test_environment.stage_gateway(triple); + test_environment.lose_plugin(triple); + let profile = environment.target_root.join(triple).join("debug"); + let mut runner = + FakeRunner::with_responses(vec![workshop_built_into(profile.clone(), success(""))]); + + let error = build_workshop( + &BuildRequest { + profile: Profile::Debug, + target: Some(triple.to_owned()), + gateway_binary: None, + }, + environment, + &mut runner, + ) + .expect_err("the library is missing"); + + let library = plugin::built_library(&profile, triple); + assert_eq!( + error.to_string(), + format!( + "cannot place the std Plugin beside Workshop: {} is missing; a Workshop build \ + writes it, because the server depends on workshop-plugin-std", + library.display() + ) + ); + assert!(!profile.join("plugins").exists()); + assert!( + !profile.join("runvil-gateway").exists(), + "the Gateway is placed only after the Plugin" + ); +} + +#[test] +fn the_bundle_configuration_copies_the_library_into_each_platform_s_bundle() { + let dylib = Path::new("release") + .join("deps") + .join("libplugin_std.dylib"); + assert_eq!( + plugin::bundle_config(System::MacOs, &dylib), + Ok(Some( + serde_json::json!({ + "bundle": { "macOS": { "files": { + "Resources/plugins/libplugin_std.dylib": dylib + } } } + }) + .to_string() + )), + "a macOS path runs from the bundle's Contents" + ); + let so = Path::new("release").join("deps").join("libplugin_std.so"); + assert_eq!( + plugin::bundle_config(System::Linux, &so), + Ok(Some( + serde_json::json!({ + "bundle": { "linux": { "appimage": { "files": { + "/usr/lib/runvil/plugins/libplugin_std.so": so + } } } } + }) + .to_string() + )), + "an AppImage path runs from the root of the AppDir" + ); + let dll = Path::new("release").join("deps").join("plugin_std.dll"); + assert_eq!( + plugin::bundle_config(System::Windows, &dll), + Ok(None), + "the Windows setup installs the library itself" + ); +} + +#[test] +fn a_bundle_tauri_left_without_the_library_fails_the_installer_and_collects_nothing() { + for (target, directory, tree) in [ + ("aarch64-apple-darwin", "macos", "Runvil.app"), + ("x86_64-unknown-linux-gnu", "appimage", "Runvil.AppDir"), + ] { + let test_environment = environment(); + let environment = &test_environment.environment; + let staged = test_environment.stage_gateway(target); + let bundle = environment + .target_root + .join(target) + .join("release") + .join("bundle") + .join(directory); + // The bundle is whole except for the library. + let built = if directory == "macos" { + bundle + .join(tree) + .join("Contents") + .join("MacOS") + .join("runvil-workshop") + } else { + bundle.join("Runvil_0.0.0_amd64.AppImage") + }; + let mut runner = FakeRunner::with_responses(vec![ + ifw_found(), + node_found(), + version_printed(), + bundled(staged, vec![built], success("")), + ]); + + let error = build_installer(&request(target, false), environment, &mut runner) + .expect_err("the bundle has no library"); + + assert!( + error + .primary + .starts_with("the Workshop bundle lacks the std Plugin's library: "), + "{target}: {error}" + ); + assert!( + error.primary.contains(tree) && error.primary.contains("libplugin_std."), + "{target}: the error names the missing file: {error}" + ); + assert!( + !environment.target_root.join("installer").exists(), + "{target}: nothing is collected" + ); + } +} + +#[test] +fn a_windows_bundle_is_not_checked_for_a_library_inside_it() { + let bundle = Path::new("release").join("bundle").join("nsis"); + assert_eq!( + plugin::check_bundled(System::Windows, &bundle, "plugin_std.dll"), + Ok(()) + ); +} diff --git a/crates/build-xtask/src/tidy-tests.rs b/crates/build-xtask/src/tidy-tests.rs index f17d9d338..e1f8516c9 100644 --- a/crates/build-xtask/src/tidy-tests.rs +++ b/crates/build-xtask/src/tidy-tests.rs @@ -43,7 +43,7 @@ fn a_tiered_crate_whose_manifest_is_missing_is_reported_not_skipped() { let root = tempfile::TempDir::new().expect("tempdir"); std::fs::create_dir_all(root.path().join("crates")).expect("the crates directory creates"); let violations = tier_dependency_violations(root.path()); - let tiered = packages(&[VOCABULARY, SERVICES, FEATURES, SERVER]); + let tiered = packages(&[VOCABULARY, PLUGINS, SERVICES, FEATURES, SERVER]); assert_eq!( violations.len(), tiered.len(), @@ -274,7 +274,12 @@ fn tier_table_grants_each_tier_only_lower_tiers() { ); assert_eq!( allowed_dependencies("workshop-server"), - Some(packages(&[VOCABULARY, SERVICES, FEATURES])) + Some(packages(&[VOCABULARY, SERVICES, FEATURES, PLUGINS])) + ); + assert_eq!( + allowed_dependencies("workshop-plugin-std"), + Some(Vec::new()), + "a library Plugin names no workshop crate" ); assert_eq!(allowed_dependencies("gateway"), None); } diff --git a/crates/build-xtask/src/tidy.rs b/crates/build-xtask/src/tidy.rs index e8ea0c8df..6106e6862 100644 --- a/crates/build-xtask/src/tidy.rs +++ b/crates/build-xtask/src/tidy.rs @@ -30,6 +30,10 @@ const VOCABULARY: &[Tiered] = &[ ("workshop-registry", "registry"), ("workshop-support", "support"), ]; +/// Tier 0: library Plugins, each the dynamic library of a Plugin whose +/// code lives in another repository. No internal `workshop-*` +/// dependencies, and only the server may name one. +const PLUGINS: &[Tiered] = &[("workshop-plugin-std", "plugin-std")]; /// Tier 1: domain services. Depend on vocabulary crates only. const SERVICES: &[Tiered] = &[ ("workshop-gateway", "gateway"), @@ -43,7 +47,8 @@ const FEATURES: &[Tiered] = &[ ("workshop-user-state", "user-state"), ("workshop-workspace", "workspace"), ]; -/// Tier 3: the server. May depend on every lower tier. +/// Tier 3: the server. May depend on every lower tier, the library +/// Plugins included. const SERVER: &[Tiered] = &[("workshop-server", "server")]; /// Marker in a crate's `lib.rs` (or `main.rs`) crate docs. Mandatory for @@ -81,14 +86,14 @@ fn allowed_dependencies(name: &str) -> Option> { // The proxy slots speak the wire types: the status push-channel // slot holds `workshop-protocol`'s `StatusBarUpdate`. vec!["workshop-protocol"] - } else if in_tier(VOCABULARY) { + } else if in_tier(VOCABULARY) || in_tier(PLUGINS) { Vec::new() } else if in_tier(SERVICES) { packages(&[VOCABULARY]) } else if in_tier(FEATURES) { packages(&[VOCABULARY, SERVICES]) } else if in_tier(SERVER) { - packages(&[VOCABULARY, SERVICES, FEATURES]) + packages(&[VOCABULARY, SERVICES, FEATURES, PLUGINS]) } else { return None; }; @@ -109,7 +114,7 @@ fn packages(tiers: &[&[Tiered]]) -> Vec<&'static str> { #[must_use] fn tier_dependency_violations(root: &Path) -> Vec { let mut violations = Vec::new(); - for (name, dir) in [VOCABULARY, SERVICES, FEATURES, SERVER].concat() { + for (name, dir) in [VOCABULARY, PLUGINS, SERVICES, FEATURES, SERVER].concat() { let Some(allowed) = allowed_dependencies(name) else { continue; }; diff --git a/crates/harness-internal/plugin-loader/Cargo.toml b/crates/harness-internal/plugin-loader/Cargo.toml index c16d0045c..b29d99ab8 100644 --- a/crates/harness-internal/plugin-loader/Cargo.toml +++ b/crates/harness-internal/plugin-loader/Cargo.toml @@ -12,8 +12,16 @@ libloading.workspace = true # The Pack registry the inserts take, `PackId`, which validates a Plugin's # name, and the universal error a failed load is. runvil-plugin.workspace = true -# The C contract every entry point follows, version 0.2, as Rust types. +# The C contract every entry point follows, version 0.4, as Rust types. runvil-plugin-sys.workspace = true +# A tool's parameters arrive as JSON text and its arguments leave as JSON +# text, because the C contract carries no JSON type. +serde_json.workspace = true + +[dev-dependencies] +# `testing::TestCall`, which lends the unit tests the context a tool call +# borrows. +runvil-plugin = { workspace = true, features = ["test-support"] } [lints] workspace = true diff --git a/crates/harness-internal/plugin-loader/src/call-tests.rs b/crates/harness-internal/plugin-loader/src/call-tests.rs new file mode 100644 index 000000000..4c0a471d5 --- /dev/null +++ b/crates/harness-internal/plugin-loader/src/call-tests.rs @@ -0,0 +1,492 @@ +//! Tests for a tool call through a Plugin's entry point: what each step +//! is sent, how a result becomes an output or a failure, when the future +//! is pending, and that RV_TOOL_CANCEL comes once for a call whose last +//! step returned RV_PENDING and for no other call. + +use std::future::Future; +use std::pin::pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::task::{Context, Poll, Wake, Waker}; + +use runvil_plugin::{ErrorCode, OutputTrust}; +use serde_json::json; + +use crate::fakes::*; + +/// The message of a call that failed because its Plugin misused it. +macro_rules! misuse_reason { + ($detail:expr) => { + concat!("the tool's Plugin misused the tool call: ", $detail) + }; +} + +/// Counts how often it is woken. +#[derive(Default)] +struct Wakes(AtomicUsize); + +impl Wake for Wakes { + fn wake(self: Arc) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +pack_fake!( + echo, + { + /// Whether every step carried the contract's fields. + pub(super) static WELL_FORMED: AtomicUsize = AtomicUsize::new(1); + }, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + add_tool(call, "echo"); + add_tool(call, "page"); + add_tool(call, "fails") + }, + step: |call| { + if !contract_holds(call, RV_TOOL_CALL) || pack_run(call) != instance(PACK_TAG) { + WELL_FORMED.store(0, Ordering::SeqCst); + } + let args = step_args(call); + let (tool, arguments) = (text(args.tool), text(args.arguments)); + match tool.as_str() { + "echo" => finish(call, RV_RESULT_TRUSTED, &format!("{tool} {arguments}")), + "page" => finish(call, RV_RESULT_UNTRUSTED, "text from outside"), + _ => finish( + call, + arguments.parse().unwrap_or(RV_RESULT_OTHER), + &format!("failed as {arguments}"), + ), + } + } +); + +#[test] +fn a_call_sends_the_tool_name_and_the_arguments_as_json_text() { + let pack = made(echo::entry); + let output = call_tool(&*pack, "echo", json!({ "b": [1, 2], "a": "x" })).expect("the call"); + assert_eq!(output.text(), r#"echo {"a":"x","b":[1,2]}"#); + assert_eq!(output.trust(), OutputTrust::Trusted); + assert_eq!(echo::WELL_FORMED.load(Ordering::SeqCst), 1); + assert_eq!(echo::CANCELS.load(Ordering::SeqCst), 0); +} + +#[test] +fn an_untrusted_result_is_an_untrusted_output() { + let pack = made(echo::entry); + let output = call_tool(&*pack, "page", json!({})).expect("the call"); + assert_eq!(output.text(), "text from outside"); + assert_eq!(output.trust(), OutputTrust::Untrusted); +} + +#[test] +fn each_failure_kind_is_a_tool_error_of_its_code_with_the_text_as_message_and_prose() { + let pack = made(echo::entry); + for (kind, code) in [ + (RV_RESULT_INVALID_ARGUMENTS, ErrorCode::ToolInvalidArguments), + (RV_RESULT_BACKEND, ErrorCode::ToolBackend), + (RV_RESULT_TRANSPORT, ErrorCode::ToolTransport), + (RV_RESULT_OTHER, ErrorCode::ToolOther), + ] { + let failure = call_tool(&*pack, "fails", json!(kind)).expect_err("the call fails"); + assert_eq!(failure.code, code, "kind {kind}"); + assert_eq!(failure.message, format!("failed as {kind}")); + assert_eq!(failure.prose, failure.message); + } +} + +pack_fake!( + slow, + { + /// The `tool_call` each step was sent, in order. + pub(super) static STATES: Mutex> = Mutex::new(Vec::new()); + }, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + add_tool(call, "slow") + }, + step: |call| { + let steps = tool_call(call).addr(); + if let Ok(mut states) = STATES.lock() { + states.push(steps); + } + if steps < 2 { + set_tool_call(call, ptr::without_provenance_mut(steps + 1)); + return RV_PENDING; + } + finish(call, RV_RESULT_TRUSTED, "done") + } +); + +#[test] +fn a_pending_step_wakes_its_task_and_the_next_poll_sends_the_next_step() { + let pack = made(slow::entry); + let call = test_call("slow"); + let mut future = pin!(pack.call(call.context(), json!({}))); + let count = Arc::new(Wakes::default()); + let waker = Waker::from(Arc::clone(&count)); + let mut cx = Context::from_waker(&waker); + + assert!(future.as_mut().poll(&mut cx).is_pending()); + assert_eq!(count.0.load(Ordering::SeqCst), 1); + assert!(future.as_mut().poll(&mut cx).is_pending()); + assert_eq!(count.0.load(Ordering::SeqCst), 2); + let Poll::Ready(result) = future.as_mut().poll(&mut cx) else { + panic!("the third step finishes the call"); + }; + assert_eq!(result.expect("the call succeeds").text(), "done"); + assert_eq!( + count.0.load(Ordering::SeqCst), + 2, + "the step that ends the call wakes nothing" + ); + assert_eq!( + *slow::STATES.lock().expect("the lock holds"), + [0, 1, 2], + "each step gets the state the last one left, and the first gets NULL" + ); + assert_eq!( + slow::CANCELS.load(Ordering::SeqCst), + 0, + "a call that ended gets no cancel" + ); +} + +pack_fake!( + abandoned, + {}, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + add_tool(call, "slow") + }, + step: |call| { + set_tool_call(call, instance(0x300)); + RV_PENDING + } +); + +#[test] +fn dropping_a_pending_call_sends_one_cancel_with_its_state_before_the_delete() { + let pack = made(abandoned::entry); + let call = test_call("slow"); + let mut future = Box::pin(pack.call(call.context(), json!({}))); + let mut cx = Context::from_waker(Waker::noop()); + assert!(future.as_mut().poll(&mut cx).is_pending()); + assert_eq!(abandoned::CANCELS.load(Ordering::SeqCst), 0); + drop(future); + assert_eq!(abandoned::CANCELS.load(Ordering::SeqCst), 1); + assert_eq!(abandoned::CANCELED.load(Ordering::SeqCst), instance(0x300)); + drop(pack); + assert_eq!( + *abandoned::ORDER.lock().expect("the lock holds"), + [RV_TOOL_CANCEL, RV_PACK_DELETE, RV_PLUGIN_UNLOAD] + ); +} + +pack_fake!( + unsent, + {}, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + add_tool(call, "slow") + }, + step: |_call| RV_PENDING +); + +#[test] +fn a_call_that_was_never_polled_sends_nothing() { + let pack = made(unsent::entry); + let call = test_call("slow"); + drop(pack.call(call.context(), json!({}))); + assert_eq!(unsent::CANCELS.load(Ordering::SeqCst), 0); +} + +pack_fake!( + stateless, + {}, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + add_tool(call, "slow") + }, + step: |_call| RV_PENDING +); + +#[test] +fn a_pending_call_that_set_no_state_is_canceled_with_null() { + let pack = made(stateless::entry); + let call = test_call("slow"); + let mut future = Box::pin(pack.call(call.context(), json!({}))); + let mut cx = Context::from_waker(Waker::noop()); + assert!(future.as_mut().poll(&mut cx).is_pending()); + drop(future); + assert_eq!(stateless::CANCELS.load(Ordering::SeqCst), 1); + assert!(stateless::CANCELED.load(Ordering::SeqCst).is_null()); +} + +/// Defines a test whose fake step runs `$step`, which returns the step's +/// status. The call must fail as a `ToolOther` error whose message is +/// `$message`, and must get `$cancels` RV_TOOL_CANCEL calls. +macro_rules! failed_call_test { + ($test:ident, $message:expr, $cancels:expr, |$call:ident| $step:expr) => { + #[test] + fn $test() { + pack_fake!( + plug, + {}, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + add_tool(call, "tool") + }, + step: |$call| $step + ); + let pack = made(plug::entry); + let failure = call_tool(&*pack, "tool", json!({})).expect_err("the call fails"); + assert_eq!(failure.code, ErrorCode::ToolOther); + assert_eq!(failure.message, $message); + assert_eq!(plug::CANCELS.load(Ordering::SeqCst), $cancels); + } + }; +} + +failed_call_test!( + a_step_that_returns_ok_without_finishing_fails_the_call, + "the tool's Plugin answered RV_TOOL_CALL with RV_OK without finishing the call", + 0, + |_call| RV_OK +); + +failed_call_test!( + a_step_that_returns_another_status_fails_the_call_with_it, + "the tool's Plugin answered RV_TOOL_CALL with status 77", + 0, + |_call| ODD_STATUS +); + +failed_call_test!( + a_status_other_than_ok_wins_over_a_result, + "the tool's Plugin answered RV_TOOL_CALL with status 77", + 0, + |call| { + finish(call, RV_RESULT_TRUSTED, "done"); + ODD_STATUS + } +); + +failed_call_test!( + a_step_that_finishes_and_returns_pending_fails_the_call_and_is_canceled, + misuse_reason!("a step called finish and then returned RV_PENDING"), + 1, + |call| { + finish(call, RV_RESULT_TRUSTED, "done"); + RV_PENDING + } +); + +failed_call_test!( + a_misuse_fails_the_call_even_when_a_good_result_follows, + misuse_reason!("the result's kind is not one of the RV_RESULT constants"), + 0, + |call| { + finish(call, 6, "what kind is this"); + finish(call, RV_RESULT_TRUSTED, "done") + } +); + +failed_call_test!( + a_misuse_in_a_step_that_returns_pending_fails_the_call_and_is_canceled, + misuse_reason!("finish was handed a NULL result"), + 1, + |call| { + finish_as(call, step_args(call).ctx, ptr::null()); + RV_PENDING + } +); + +failed_call_test!( + a_second_finish_is_misuse, + misuse_reason!("finish was called twice"), + 0, + |call| { + finish(call, RV_RESULT_TRUSTED, "first"); + finish(call, RV_RESULT_TRUSTED, "second") + } +); + +failed_call_test!( + a_finish_through_another_ctx_is_misuse, + misuse_reason!("a tool call function was handed a ctx other than the open RV_TOOL_CALL step's"), + 0, + |call| { + let done = result(RV_RESULT_TRUSTED, rv_str("done")); + finish_as(call, ptr::null_mut(), &raw const done); + RV_OK + } +); + +failed_call_test!( + a_misaligned_result_is_misuse, + misuse_reason!("finish was handed a misaligned result"), + 0, + |call| { + finish_as(call, step_args(call).ctx, ptr::without_provenance(1)); + RV_OK + } +); + +failed_call_test!( + a_short_result_size_is_misuse, + misuse_reason!("the result's size is shorter than RvToolResult"), + 0, + |call| { + finish_result( + call, + &RvToolResult { + size: 23, + ..result(RV_RESULT_TRUSTED, rv_str("done")) + }, + ); + RV_OK + } +); + +failed_call_test!( + a_result_text_that_is_not_utf8_is_misuse, + misuse_reason!("the result's text is not UTF-8"), + 0, + |call| { + finish_result( + call, + &result(RV_RESULT_TRUSTED, rv_str_bytes(&[0xff, 0xfe])), + ); + RV_OK + } +); + +failed_call_test!( + a_result_text_with_a_null_pointer_and_a_length_is_misuse, + misuse_reason!("the result's text has a NULL pointer and a nonzero length"), + 0, + |call| { + finish_result( + call, + &result( + RV_RESULT_TRUSTED, + RvStr { + ptr: ptr::null(), + len: 4, + }, + ), + ); + RV_OK + } +); + +failed_call_test!( + a_result_text_over_16_mebibytes_is_misuse, + misuse_reason!("the result's text is longer than 16777216 bytes"), + 0, + |call| { + // The length is refused before any byte is read, so the text + // behind it need not be that long. + finish_result( + call, + &result( + RV_RESULT_TRUSTED, + RvStr { + ptr: "x".as_ptr().cast(), + len: 16_777_217, + }, + ), + ); + RV_OK + } +); + +failed_call_test!( + a_load_function_called_during_a_step_is_misuse, + misuse_reason!("a load function was called outside RV_PLUGIN_LOAD"), + 0, + |call| { + describe_with(step_args(call).ctx, "acme-url", "1.0.0"); + RV_OK + } +); + +#[test] +fn an_empty_result_text_is_accepted() { + pack_fake!( + plug, + {}, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + add_tool(call, "tool") + }, + step: |call| finish_result( + call, + &result( + RV_RESULT_TRUSTED, + RvStr { + ptr: ptr::null(), + len: 0, + }, + ), + ) + ); + let pack = made(plug::entry); + let output = call_tool(&*pack, "tool", json!({})).expect("the call succeeds"); + assert_eq!(output.text(), ""); +} + +#[test] +fn a_call_polled_after_it_ended_fails_and_sends_no_step() { + pack_fake!( + plug, + { + pub(super) static STEPS: AtomicUsize = AtomicUsize::new(0); + }, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + add_tool(call, "tool") + }, + step: |call| { + STEPS.fetch_add(1, Ordering::SeqCst); + finish(call, RV_RESULT_TRUSTED, "done") + } + ); + let pack = made(plug::entry); + let call = test_call("tool"); + let mut future = pin!(pack.call(call.context(), json!({}))); + let mut cx = Context::from_waker(Waker::noop()); + assert!(future.as_mut().poll(&mut cx).is_ready()); + let Poll::Ready(Err(failure)) = future.as_mut().poll(&mut cx) else { + panic!("a second poll after the end fails"); + }; + assert_eq!(failure.message, "the tool call was polled after it ended"); + assert_eq!(plug::STEPS.load(Ordering::SeqCst), 1); +} + +#[test] +fn a_late_finish_through_a_stashed_ctx_is_misuse() { + pack_fake!( + plug, + { + pub(super) static STEP_CTX: AtomicPtr = AtomicPtr::new(ptr::null_mut()); + }, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + add_tool(call, "tool") + }, + step: |call| { + STEP_CTX.store(step_args(call).ctx, Ordering::SeqCst); + finish(call, RV_RESULT_TRUSTED, "done") + } + ); + let pack = made(plug::entry); + call_tool(&*pack, "tool", json!({})).expect("the call succeeds"); + let stashed = plug::STEP_CTX.load(Ordering::SeqCst); + let late = result(RV_RESULT_TRUSTED, rv_str("late")); + // SAFETY: the loader's function compares a `ctx` with the open call's + // before it reads through it, and no call is open on this thread. + let status = unsafe { crate::call::finish(stashed, &raw const late) }; + assert_eq!(status, RV_ERR_MISUSE); +} diff --git a/crates/harness-internal/plugin-loader/src/call.rs b/crates/harness-internal/plugin-loader/src/call.rs new file mode 100644 index 000000000..76a684bb5 --- /dev/null +++ b/crates/harness-internal/plugin-loader/src/call.rs @@ -0,0 +1,230 @@ +//! One tool call of a Pack instance that a Plugin's entry point made: the +//! future that sends RV_TOOL_CALL once each time it is polled, the +//! function an `RvToolCallArgs` carries to end the call, and the sender +//! of RV_TOOL_CANCEL. + +use std::ffi::c_void; +use std::future::Future; +use std::pin::Pin; +use std::ptr; +use std::task::{Context, Poll}; + +use runvil_plugin::{Access, Error, ErrorCode, ToolOutput, make_tool_error}; +use runvil_plugin_sys::{ + RV_MAX_RESULT, RV_OK, RV_PENDING, RV_RESULT_BACKEND, RV_RESULT_INVALID_ARGUMENTS, + RV_RESULT_OTHER, RV_RESULT_TRANSPORT, RV_RESULT_TRUSTED, RV_RESULT_UNTRUSTED, RV_TOOL_CALL, + RV_TOOL_CANCEL, RvCall, RvStatus, RvToolCallArgs, RvToolResult, +}; +use serde_json::Value; + +use crate::file::{file_list, file_read, file_stat}; +use crate::pack::EntryPack; +use crate::session::{ + self, Field, Session, Shape, TOOL_CALL, answer, field, rv_str, shape, size_field, +}; + +#[cfg(test)] +#[path = "call-tests.rs"] +mod tests; + +const RESULT: Shape = shape!("finish", "result", "RvToolResult"); +const TEXT: Field = field!("the result's text", 16_777_216, RV_MAX_RESULT); + +/// One tool call in flight. Each poll sends one RV_TOOL_CALL step. A step +/// that returns RV_PENDING wakes the future's own task and leaves it +/// pending, so the Host's executor gives the run's other work a turn and +/// then polls again. Dropping the future while its last step returned +/// RV_PENDING sends RV_TOOL_CANCEL, so the Plugin frees the call's state. +/// +/// It borrows the Pack instance, so the instance is deleted only after +/// every one of its calls has ended. +pub(crate) struct Steps<'a> { + pack: &'a EntryPack, + /// The call's files, which each step's file functions read. + access: &'a Access, + /// The tool's name within its Pack. + tool: String, + /// The JSON text of the call's arguments. + arguments: String, + /// What the last step left in `call->tool_call`, which the loader + /// passes back and never reads through. + state: *mut c_void, + /// Whether the last step returned RV_PENDING, so the Plugin still + /// holds state for the call. + pending: bool, + /// Whether the call has ended. + ended: bool, +} + +// SAFETY: the `# Threads and lifetime` section of `runvil-plugin-sys` lets +// the steps of one tool call, and its RV_TOOL_CANCEL, come on different +// threads, and the loader never reads through `state`. Every other field +// is `Send`, which the assertion below checks for the two borrows. +unsafe impl Send for Steps<'_> {} + +const _: () = { + const fn is_send() {} + is_send::<(&EntryPack, &Access)>(); +}; + +impl<'a> Steps<'a> { + /// A call of the tool named `tool` on `pack` with `arguments`, over + /// the files `access` reaches. Nothing is sent until the first poll. + pub(crate) fn new( + pack: &'a EntryPack, + tool: &str, + arguments: &Value, + access: &'a Access, + ) -> Steps<'a> { + Steps { + pack, + access, + tool: tool.to_owned(), + arguments: arguments.to_string(), + state: ptr::null_mut(), + pending: false, + ended: false, + } + } + + /// An `RvCall` that sends `verb` for this call. + fn new_call(&self, verb: u32) -> RvCall { + RvCall { + tool_call: self.state, + ..self.pack.new_call(verb) + } + } + + /// Sends one RV_TOOL_CALL step, and returns the call's result once it + /// has ended. + fn step(&mut self) -> Option> { + let mut call = self.new_call(RV_TOOL_CALL); + let (status, mut session) = session::open_step(self.access, |ctx| { + let args = RvToolCallArgs { + size: size_field::(), + reserved: 0, + ctx, + tool: rv_str(&self.tool), + arguments: rv_str(&self.arguments), + finish, + file_stat, + file_list, + file_read, + }; + call.args = (&raw const args).cast(); + // SAFETY: `call` carries this call's own pointers and a live + // `RvToolCallArgs`, and no other step of the call runs, + // because a step needs `&mut self`. + unsafe { self.pack.send(&mut call) } + }); + self.state = call.tool_call; + self.pending = status == RV_PENDING; + let outcome = judge(status, &mut session); + if outcome.is_some() { + self.end(); + } + outcome + } + + /// Ends the call: sends RV_TOOL_CANCEL when its last step returned + /// RV_PENDING, so the Plugin frees the state it still holds. + fn end(&mut self) { + self.ended = true; + if !std::mem::take(&mut self.pending) { + return; + } + let mut call = self.new_call(RV_TOOL_CANCEL); + // SAFETY: the call's last step returned RV_PENDING and no step + // runs, so this is the one cancel the contract allows it. + let _status = session::closed(|| unsafe { self.pack.send(&mut call) }); + } +} + +impl Future for Steps<'_> { + type Output = Result; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + if this.ended { + return Poll::Ready(Err(failure( + "the tool call was polled after it ended".to_owned(), + ))); + } + if let Some(result) = this.step() { + return Poll::Ready(result); + } + // The contract has no wake function: a pending call gets its next + // step as soon as the executor polls again. + cx.waker().wake_by_ref(); + Poll::Pending + } +} + +impl Drop for Steps<'_> { + fn drop(&mut self) { + self.end(); + } +} + +/// A failed call that is the Plugin's fault or the loader's, with +/// `message` as the text a model reads. +fn failure(message: String) -> Error { + make_tool_error(ErrorCode::ToolOther, message) +} + +/// What a step that returned `status` means: `None` for a call that needs +/// another step, and otherwise how the call ended. The checks come in +/// this order: a misuse, then a result beside RV_PENDING, then a status +/// other than RV_OK, then no result. +fn judge(status: RvStatus, session: &mut Session) -> Option> { + if let Some(misuse) = session.misuse.take() { + return Some(Err(failure(format!( + "the tool's Plugin misused the tool call: {misuse}" + )))); + } + match (status, session.result.take()) { + (RV_PENDING, None) => None, + (RV_PENDING, Some(_)) => Some(Err(failure( + "the tool's Plugin misused the tool call: a step called finish and then returned \ + RV_PENDING" + .to_owned(), + ))), + (RV_OK, Some(result)) => Some(result), + (RV_OK, None) => Some(Err(failure( + "the tool's Plugin answered RV_TOOL_CALL with RV_OK without finishing the call" + .to_owned(), + ))), + (status, _) => Some(Err(failure(format!( + "the tool's Plugin answered RV_TOOL_CALL with status {status}" + )))), + } +} + +/// Records the result a step ends its call with, validated; returns RV_OK +/// or RV_ERR_MISUSE. +pub(crate) unsafe extern "C" fn finish(ctx: *mut c_void, result: *const RvToolResult) -> RvStatus { + answer(ctx, &TOOL_CALL, |session| { + if session.result.is_some() { + return Err("finish was called twice"); + } + // SAFETY: the contract makes `result` point at a readable + // `RvToolResult` during this call. + let result = unsafe { RESULT.read(result) }?; + let code = match result.kind { + RV_RESULT_UNTRUSTED | RV_RESULT_TRUSTED => None, + RV_RESULT_INVALID_ARGUMENTS => Some(ErrorCode::ToolInvalidArguments), + RV_RESULT_BACKEND => Some(ErrorCode::ToolBackend), + RV_RESULT_TRANSPORT => Some(ErrorCode::ToolTransport), + RV_RESULT_OTHER => Some(ErrorCode::ToolOther), + _ => return Err("the result's kind is not one of the RV_RESULT constants"), + }; + // SAFETY: the contract makes the text valid for its length. + let text = unsafe { TEXT.read(result.text) }?; + session.result = Some(match code { + Some(code) => Err(make_tool_error(code, text)), + None if result.kind == RV_RESULT_TRUSTED => Ok(ToolOutput::trusted(text)), + None => Ok(ToolOutput::untrusted(text)), + }); + Ok(RV_OK) + }) +} diff --git a/crates/harness-internal/plugin-loader/src/fakes.rs b/crates/harness-internal/plugin-loader/src/fakes.rs index 1c9573d32..32dd431df 100644 --- a/crates/harness-internal/plugin-loader/src/fakes.rs +++ b/crates/harness-internal/plugin-loader/src/fakes.rs @@ -1,17 +1,29 @@ //! Fake entry points for the unit tests: plain `extern "C" fn`s written -//! against `runvil-plugin-sys`, and the helpers that call the load +//! against `runvil-plugin-sys`, and the helpers that call the Host's //! functions the way a Plugin does. Each test defines its own fake with -//! `fake!`, so its statics belong to it alone. +//! `fake!` or `pack_fake!`, so its statics belong to it alone. use std::ffi::c_void; +use std::future::Future; +use std::pin::pin; use std::ptr; +use std::slice; use std::sync::Arc; +use std::task::{Context, Poll, Waker}; -use runvil_plugin::{Error, PackRegistry}; +use runvil_plugin::testing::TestCall; +use runvil_plugin::{Error, Pack, PackRegistry, ToolId, ToolOutput}; pub(crate) use runvil_plugin_sys::{ - PluginEntry, RV_ERR_MISUSE, RV_ERR_REFUSED, RV_ERR_UNSUPPORTED, RV_OK, RV_PLUGIN_LOAD, - RV_PLUGIN_UNLOAD, RvCall, RvHost, RvLoadArgs, RvPluginInfo, RvStatus, RvStr, RvVerb, + PluginEntry, RV_ERR_MISUSE, RV_ERR_REFUSED, RV_ERR_UNSUPPORTED, RV_FILE_ERROR_IS_A_DIRECTORY, + RV_FILE_ERROR_NOT_A_DIRECTORY, RV_FILE_ERROR_NOT_FOUND, RV_FILE_ERROR_OTHER, + RV_FILE_TYPE_DIRECTORY, RV_FILE_TYPE_FILE, RV_OK, RV_PACK_DELETE, RV_PACK_NEW, RV_PENDING, + RV_PLUGIN_LOAD, RV_PLUGIN_UNLOAD, RV_RESULT_BACKEND, RV_RESULT_INVALID_ARGUMENTS, + RV_RESULT_OTHER, RV_RESULT_TRANSPORT, RV_RESULT_TRUSTED, RV_RESULT_UNTRUSTED, RV_TOOL_CALL, + RV_TOOL_CANCEL, RvBytes, RvCall, RvDirEntries, RvFileError, RvHost, RvLoadArgs, RvPackInfo, + RvPackNewArgs, RvPluginInfo, RvResultKind, RvStat, RvStatus, RvStr, RvToolCallArgs, RvToolInfo, + RvToolResult, RvVerb, }; +use serde_json::Value; use crate::host::{self, HOST}; use crate::plugin::{Plugin, insert_plugin}; @@ -19,6 +31,12 @@ use crate::plugin::{Plugin, insert_plugin}; /// A status no verb returns, for a fake that fails with its own code. pub(crate) const ODD_STATUS: RvStatus = 77; +/// The name of the Pack a `pack_fake!` inserts. +pub(crate) const FAKE_PACK: &str = "acme/fake"; + +/// The parameters of a fake's tools: an object with no properties. +pub(crate) const NO_PARAMETERS: &str = r#"{"type":"object","properties":{}}"#; + /// Defines `mod $name` holding a fake entry point and its statics. Its /// `RV_PLUGIN_LOAD` stores the `ctx` it was handed in `LOAD_CTX`, then runs /// `$load`, which sees the call as `$call` and returns the load's status. @@ -79,6 +97,99 @@ macro_rules! fake { } pub(crate) use fake; +/// Defines `mod $name` holding a fake entry point whose Plugin, named +/// `fake`, inserts the one Pack [`FAKE_PACK`] and sets the instance +/// [`PLUGIN_TAG`]. Its `RV_PACK_NEW` runs `$new` and its `RV_TOOL_CALL` +/// runs `$step`, each seeing the call under the name it gives and +/// returning the call's status. It counts its `RV_PLUGIN_UNLOAD` calls in +/// `UNLOADS`, its `RV_PACK_DELETE` calls in `DELETES`, and its +/// `RV_TOOL_CANCEL` calls in `CANCELS`, keeps the pointer the last delete +/// and the last cancel carried in `DELETED` and `CANCELED`, and keeps in +/// `ORDER` the verbs of those three kinds, in the order they came. The +/// items in the braces are what a test needs the bodies to share with it. +macro_rules! pack_fake { + ( + $name:ident, + { $($items:item)* }, + new: |$new_call:ident| $new:expr, + step: |$step_call:ident| $step:expr + ) => { + mod $name { + use std::ffi::c_void; + use std::ptr; + use std::sync::Mutex; + use std::sync::atomic::{AtomicPtr, AtomicUsize, Ordering}; + + use $crate::fakes::*; + + /// How many RV_PLUGIN_UNLOAD calls this fake got. + pub(super) static UNLOADS: AtomicUsize = AtomicUsize::new(0); + /// How many RV_PACK_DELETE calls this fake got. + pub(super) static DELETES: AtomicUsize = AtomicUsize::new(0); + /// The `pack_run` its last RV_PACK_DELETE carried. + pub(super) static DELETED: AtomicPtr = AtomicPtr::new(ptr::null_mut()); + /// How many RV_TOOL_CANCEL calls this fake got. + pub(super) static CANCELS: AtomicUsize = AtomicUsize::new(0); + /// The `tool_call` its last RV_TOOL_CANCEL carried. + pub(super) static CANCELED: AtomicPtr = AtomicPtr::new(ptr::null_mut()); + /// The cancels, deletes, and unloads it got, in order. + pub(super) static ORDER: Mutex> = Mutex::new(Vec::new()); + + $($items)* + + /// Notes that `verb` came. + fn note(verb: RvVerb) { + if let Ok(mut order) = ORDER.lock() { + order.push(verb); + } + } + + pub(super) extern "C" fn entry(call: *mut RvCall) -> RvStatus { + match verb(call) { + RV_PLUGIN_LOAD => { + set_plugin(call, instance(PLUGIN_TAG)); + describe(call, "fake", "1.0.0"); + insert_pack(call, FAKE_PACK, "A fake Pack") + } + RV_PLUGIN_UNLOAD => { + UNLOADS.fetch_add(1, Ordering::SeqCst); + note(RV_PLUGIN_UNLOAD); + RV_OK + } + RV_PACK_NEW => { + let $new_call = call; + $new + } + RV_PACK_DELETE => { + DELETES.fetch_add(1, Ordering::SeqCst); + DELETED.store(pack_run(call), Ordering::SeqCst); + note(RV_PACK_DELETE); + RV_OK + } + RV_TOOL_CALL => { + let $step_call = call; + $step + } + RV_TOOL_CANCEL => { + CANCELS.fetch_add(1, Ordering::SeqCst); + CANCELED.store(tool_call(call), Ordering::SeqCst); + note(RV_TOOL_CANCEL); + RV_OK + } + _ => RV_ERR_UNSUPPORTED, + } + } + } + }; +} +pub(crate) use pack_fake; + +/// The instance a `pack_fake!` Plugin sets. +pub(crate) const PLUGIN_TAG: usize = 0x100; + +/// The `pack_run` a well-behaved fake `RV_PACK_NEW` sets. +pub(crate) const PACK_TAG: usize = 0x200; + /// Inserts the linked Plugin `entry` into a fresh Pack registry. pub(crate) fn insert(entry: PluginEntry) -> Result, Error> { insert_plugin(&mut PackRegistry::new(), entry) @@ -102,37 +213,90 @@ pub(crate) fn reason(failure: &Error) -> Option<&str> { .as_str() } +/// Inserts the `pack_fake!` Plugin `entry` into a fresh registry, drops +/// the registry's hold on the Plugin's `Arc`, and asks the factory of +/// [`FAKE_PACK`] for one run's instance. +pub(crate) fn new_pack(entry: PluginEntry) -> Result, Error> { + let mut packs = PackRegistry::new(); + drop(insert_plugin(&mut packs, entry).expect("the Plugin loads")); + let (_info, id, factory) = packs + .into_entries() + .find(|(info, _, _)| info.fqn == FAKE_PACK) + .expect("the Plugin inserted its Pack"); + factory.expect("the Pack is usable").new_pack(&id) +} + +/// One run's instance of the Pack the `pack_fake!` Plugin `entry` inserts, +/// which must be made. +pub(crate) fn made(entry: PluginEntry) -> Arc { + new_pack(entry).expect("the Pack instance is made") +} + +/// A call of the fake Pack's tool `name`, for a test to lend its context. +pub(crate) fn test_call(name: &str) -> TestCall { + TestCall::new(ToolId::parse(&format!("fake/{name}")).expect("a two-segment tool id parses")) +} + +/// Drives `future` to its output, polling again after each `Pending`. +pub(crate) fn block_on(future: F) -> F::Output { + let mut future = pin!(future); + let mut cx = Context::from_waker(Waker::noop()); + loop { + if let Poll::Ready(output) = future.as_mut().poll(&mut cx) { + return output; + } + } +} + +/// Runs one call of the tool `name` on `pack` with `args` to its end. +pub(crate) fn call_tool(pack: &dyn Pack, name: &str, args: Value) -> Result { + let call = test_call(name); + block_on(pack.call(call.context(), args)) +} + /// `size_of::()` as a contract struct's `u32` size field. -fn size_u32() -> u32 { +pub(crate) fn size_u32() -> u32 { u32::try_from(size_of::()).unwrap_or(0) } /// Whether `call` carries the fields the contract gives a call that sends -/// `verb` in version 0.2, and its table says the contract's version. A -/// load call carries no instance yet and an `RvLoadArgs` with a `ctx`; an -/// unload call carries an instance and no arguments. +/// `verb` in version 0.4, and its table says the contract's version. Each +/// verb carries the Plugin's pointers the contract names for it and no +/// others, and its own arguments struct, with a `ctx`, or no arguments. pub(crate) fn contract_holds(call: *mut RvCall, verb: RvVerb) -> bool { // SAFETY: a fake runs only inside an entry point call, where `call` is // the loader's live `RvCall` and its table is the loader's static. let (call, table) = unsafe { (&*call, &*(*call).host) }; - let is_load = verb == RV_PLUGIN_LOAD; - let args_hold = if is_load && !call.args.is_null() { - // SAFETY: a load call's `args`, when not NULL, is the loader's live - // `RvLoadArgs`. - let args = unsafe { &*call.args.cast::() }; - args.size == size_u32::() && args.reserved == 0 && !args.ctx.is_null() - } else { - !is_load && call.args.is_null() + let has_plugin = verb != RV_PLUGIN_LOAD; + let has_pack = matches!(verb, RV_PACK_DELETE | RV_TOOL_CALL | RV_TOOL_CANCEL); + // The first field of each arguments struct is its size, then a zero + // `reserved`, then the `ctx`, as `RvLoadArgs` lays them out. + let args_size = match verb { + RV_PLUGIN_LOAD => Some(size_u32::()), + RV_PACK_NEW => Some(size_u32::()), + RV_TOOL_CALL => Some(size_u32::()), + _ => None, + }; + let args_hold = match args_size { + Some(size) if !call.args.is_null() => { + // SAFETY: each arguments struct begins with the three fields + // of an `RvLoadArgs` this reads, and is live for the call. + let args = unsafe { &*call.args.cast::() }; + args.size == size && args.reserved == 0 && !args.ctx.is_null() + } + Some(_) => false, + None => call.args.is_null(), }; call.size == size_u32::() && call.verb == verb && ptr::eq(call.host, &raw const HOST) - && call.plugin.is_null() == is_load - && call.pack_run.is_null() + && call.plugin.is_null() != has_plugin + && (has_pack || call.pack_run.is_null()) + && (matches!(verb, RV_TOOL_CALL | RV_TOOL_CANCEL) || call.tool_call.is_null()) && args_hold && table.size == size_u32::() && table.api_major == 0 - && table.api_minor == 2 + && table.api_minor == 4 && table.reserved == 0 } @@ -155,6 +319,30 @@ pub(crate) fn set_plugin(call: *mut RvCall, plugin: *mut c_void) { unsafe { (*call).plugin = plugin }; } +/// The Pack instance pointer a call carries. +pub(crate) fn pack_run(call: *mut RvCall) -> *mut c_void { + // SAFETY: as in `contract_holds`. + unsafe { (*call).pack_run } +} + +/// Sets the Pack instance pointer, as RV_PACK_NEW may. +pub(crate) fn set_pack_run(call: *mut RvCall, pack_run: *mut c_void) { + // SAFETY: as in `set_plugin`. + unsafe { (*call).pack_run = pack_run }; +} + +/// The tool call state a call carries. +pub(crate) fn tool_call(call: *mut RvCall) -> *mut c_void { + // SAFETY: as in `contract_holds`. + unsafe { (*call).tool_call } +} + +/// Sets the tool call state, as a step may. +pub(crate) fn set_tool_call(call: *mut RvCall, state: *mut c_void) { + // SAFETY: as in `set_plugin`. + unsafe { (*call).tool_call = state }; +} + /// An address that is never null, which a fake hands out as its instance. pub(crate) fn instance(tag: usize) -> *mut c_void { ptr::without_provenance_mut(tag) @@ -168,6 +356,18 @@ fn args<'call>(call: *mut RvCall) -> &'call RvLoadArgs { unsafe { &*(*call).args.cast::() } } +/// The `RvPackNewArgs` an RV_PACK_NEW call carries. +pub(crate) fn pack_args<'call>(call: *mut RvCall) -> &'call RvPackNewArgs { + // SAFETY: as in `args`, for the verb's own arguments struct. + unsafe { &*(*call).args.cast::() } +} + +/// The `RvToolCallArgs` an RV_TOOL_CALL step carries. +pub(crate) fn step_args<'call>(call: *mut RvCall) -> &'call RvToolCallArgs { + // SAFETY: as in `args`, for the verb's own arguments struct. + unsafe { &*(*call).args.cast::() } +} + /// The `ctx` a load call's `RvLoadArgs` carries. pub(crate) fn ctx(call: *mut RvCall) -> *mut c_void { args(call).ctx @@ -189,10 +389,21 @@ pub(crate) fn rv_str_bytes(bytes: &[u8]) -> RvStr { } } +/// The text a string from the loader holds. +pub(crate) fn text(s: RvStr) -> String { + if s.len == 0 { + return String::new(); + } + // SAFETY: the loader hands a fake strings that are valid for their + // length during the call. + let bytes = unsafe { slice::from_raw_parts(s.ptr.cast::(), s.len) }; + String::from_utf8_lossy(bytes).into_owned() +} + /// A well-formed `RvPluginInfo`. pub(crate) fn info(name: RvStr, version: RvStr) -> RvPluginInfo { RvPluginInfo { - size: u32::try_from(size_of::()).expect("the info's size fits a u32"), + size: size_u32::(), reserved: 0, name, version, @@ -253,3 +464,244 @@ pub(crate) fn refuse_with(ctx: *mut c_void, reason: &str) -> RvStatus { // SAFETY: as in `describe_with`. unsafe { host::refuse(ctx, rv_str(reason)) } } + +/// A well-formed `RvPackInfo`. +pub(crate) fn pack_info(name: RvStr, description: RvStr) -> RvPackInfo { + RvPackInfo { + size: size_u32::(), + reserved: 0, + name, + description, + } +} + +/// Calls the load call's `insert_pack` with its own `ctx` and `info`. +pub(crate) fn insert_pack_info(call: *mut RvCall, info: &RvPackInfo) -> RvStatus { + // SAFETY: as in `describe_as`. + unsafe { (args(call).insert_pack)(ctx(call), info) } +} + +/// Inserts the Pack `name` with `description`. +pub(crate) fn insert_pack(call: *mut RvCall, name: &str, description: &str) -> RvStatus { + insert_pack_info(call, &pack_info(rv_str(name), rv_str(description))) +} + +/// A well-formed `RvToolInfo` for the tool `name`, with no wire name, a +/// one-line description, and [`NO_PARAMETERS`]. +pub(crate) fn tool_info(name: &str) -> RvToolInfo { + RvToolInfo { + size: size_u32::(), + reserved: 0, + name: rv_str(name), + wire_name: rv_str(""), + description: rv_str("A fake tool."), + parameters: rv_str(NO_PARAMETERS), + } +} + +/// Calls the `add_tool` of an RV_PACK_NEW call, handing it `as_ctx`. +pub(crate) fn add_tool_as(call: *mut RvCall, as_ctx: *mut c_void, info: &RvToolInfo) -> RvStatus { + // SAFETY: `info` is a live `RvToolInfo` whose strings outlive the + // call, and the function reads through `as_ctx` only when it is the + // `ctx` of the call open on this thread. + unsafe { (pack_args(call).add_tool)(as_ctx, info) } +} + +/// Calls the call's `add_tool` with its own `ctx` and `info`. +pub(crate) fn add_tool_info(call: *mut RvCall, info: &RvToolInfo) -> RvStatus { + add_tool_as(call, pack_args(call).ctx, info) +} + +/// Adds the tool `name` to the instance being made. +pub(crate) fn add_tool(call: *mut RvCall, name: &str) -> RvStatus { + add_tool_info(call, &tool_info(name)) +} + +/// Declines the RV_PACK_NEW call with `reason`. +pub(crate) fn refuse_pack(call: *mut RvCall, reason: &str) -> RvStatus { + let args = pack_args(call); + // SAFETY: `reason` outlives the call, and `ctx` is the open call's. + unsafe { (args.refuse)(args.ctx, rv_str(reason)) } +} + +/// A well-formed `RvToolResult`. +pub(crate) fn result(kind: RvResultKind, text: RvStr) -> RvToolResult { + RvToolResult { + size: size_u32::(), + kind, + text, + } +} + +/// Calls the `finish` of an RV_TOOL_CALL step, handing it `as_ctx`. +pub(crate) fn finish_as( + call: *mut RvCall, + as_ctx: *mut c_void, + result: *const RvToolResult, +) -> RvStatus { + // SAFETY: `result` is NULL, misaligned on purpose, or a live + // `RvToolResult` whose text outlives the call, and the function reads + // through `as_ctx` only when it is the `ctx` of the step open on this + // thread. + unsafe { (step_args(call).finish)(as_ctx, result) } +} + +/// Calls the step's `finish` with its own `ctx` and `result`. +pub(crate) fn finish_result(call: *mut RvCall, result: &RvToolResult) -> RvStatus { + finish_as(call, step_args(call).ctx, result) +} + +/// Finishes the call with `kind` and `text`. +pub(crate) fn finish(call: *mut RvCall, kind: RvResultKind, text: &str) -> RvStatus { + finish_result(call, &result(kind, rv_str(text))) +} + +/// An `RvStat` whose every field is 9, so a test sees whether a file +/// function wrote it. +pub(crate) fn unset_stat() -> RvStat { + RvStat { + file_type: 9, + reserved: 9, + len: 9, + } +} + +/// An `RvFileError` of kind 9 that says `untouched`, so a test sees +/// whether a file function wrote it. +pub(crate) fn unset_error() -> RvFileError { + RvFileError { + kind: 9, + reserved: 9, + text: rv_str("untouched"), + } +} + +/// An `RvBytes` with no pointer and a length of 9, so a test sees whether +/// a file function wrote it. +pub(crate) fn unset_bytes() -> RvBytes { + RvBytes { + ptr: ptr::null(), + len: 9, + } +} + +/// An `RvDirEntries` with no pointer and a length of 9, so a test sees +/// whether a file function wrote it. +pub(crate) fn unset_entries() -> RvDirEntries { + RvDirEntries { + ptr: ptr::null(), + len: 9, + } +} + +/// Calls the step's `file_stat` with `as_ctx`, as a Plugin would or as it +/// must not. +pub(crate) fn stat_as( + call: *mut RvCall, + as_ctx: *mut c_void, + path: RvStr, + stat: *mut RvStat, + error: *mut RvFileError, +) -> RvStatus { + // SAFETY: the function refuses a `ctx` that is not the open step's, a + // path it cannot read, and a pointer that is NULL or misaligned, + // before it reads or writes through any of them. Every other pointer + // a test hands over is a live struct of its own. + unsafe { (step_args(call).file_stat)(as_ctx, path, stat, error) } +} + +/// Calls the step's `file_list` with `as_ctx`, as `stat_as` calls +/// `file_stat`. +pub(crate) fn list_as( + call: *mut RvCall, + as_ctx: *mut c_void, + path: RvStr, + entries: *mut RvDirEntries, + error: *mut RvFileError, +) -> RvStatus { + // SAFETY: as in `stat_as`. + unsafe { (step_args(call).file_list)(as_ctx, path, entries, error) } +} + +/// Calls the step's `file_read` with `as_ctx`, as `stat_as` calls +/// `file_stat`. +pub(crate) fn read_as( + call: *mut RvCall, + as_ctx: *mut c_void, + path: RvStr, + range: (u64, u64), + bytes: *mut RvBytes, + error: *mut RvFileError, +) -> RvStatus { + // SAFETY: as in `stat_as`. + unsafe { (step_args(call).file_read)(as_ctx, path, range.0, range.1, bytes, error) } +} + +/// What a file function of a step answered: what it handed back, or the +/// kind and the text of its failure. +pub(crate) type Answer = Result; + +/// What the step's `file_stat` says of `path`: the file type and the +/// length, or the kind and the text of the failure. +pub(crate) fn stat_of(call: *mut RvCall, path: &str) -> Answer<(u32, u64)> { + let (mut stat, mut error) = (unset_stat(), unset_error()); + let ctx = step_args(call).ctx; + match stat_as(call, ctx, rv_str(path), &raw mut stat, &raw mut error) { + RV_OK => Ok((stat.file_type, stat.len)), + _ => Err((error.kind, text(error.text))), + } +} + +/// What the step's `file_list` says of `path`: each entry's name, file +/// type, and length, or the kind and the text of the failure. +pub(crate) fn list_of(call: *mut RvCall, path: &str) -> Answer> { + let (mut entries, mut error) = (unset_entries(), unset_error()); + let ctx = step_args(call).ctx; + match list_as(call, ctx, rv_str(path), &raw mut entries, &raw mut error) { + RV_OK if entries.len == 0 => Ok(Vec::new()), + RV_OK => { + // SAFETY: the loader keeps the entries and their names valid + // until the step's next file function. + let listed = unsafe { slice::from_raw_parts(entries.ptr, entries.len) }; + Ok(listed + .iter() + .map(|entry| (text(entry.name), entry.stat.file_type, entry.stat.len)) + .collect()) + } + _ => Err((error.kind, text(error.text))), + } +} + +/// What the step's `file_read` hands back for `path` and the range +/// `offset` and `len`, or the kind and the text of the failure. +pub(crate) fn read_of(call: *mut RvCall, path: &str, range: (u64, u64)) -> Answer> { + let (mut bytes, mut error) = (unset_bytes(), unset_error()); + let ctx = step_args(call).ctx; + match read_as( + call, + ctx, + rv_str(path), + range, + &raw mut bytes, + &raw mut error, + ) { + RV_OK if bytes.len == 0 => Ok(Vec::new()), + // SAFETY: the loader keeps the bytes valid until the step's next + // file function. + RV_OK => Ok(unsafe { slice::from_raw_parts(bytes.ptr, bytes.len) }.to_vec()), + _ => Err((error.kind, text(error.text))), + } +} + +/// A call of the fake Pack's tool `name` over an in-memory filesystem +/// that holds each of `files`, a path beside its text. +pub(crate) fn seeded_call(name: &str, files: &[(&str, &str)]) -> TestCall { + let call = test_call(name); + for (path, text) in files { + call.context() + .access() + .write(path, text.as_bytes()) + .expect("a seeded file is written"); + } + call +} diff --git a/crates/harness-internal/plugin-loader/src/file-tests.rs b/crates/harness-internal/plugin-loader/src/file-tests.rs new file mode 100644 index 000000000..9bf6abcc5 --- /dev/null +++ b/crates/harness-internal/plugin-loader/src/file-tests.rs @@ -0,0 +1,700 @@ +//! Tests for the file functions of a tool call's step: what each hands +//! back from the call's access, how a `VfsError` reads to a Plugin, that a +//! failed file function leaves the call running, and what each is refused +//! for as a misuse. + +use std::io; +use std::sync::atomic::Ordering; + +use runvil_plugin::{FileType, PathReason, Stat, VfsError}; +use runvil_plugin_sys::{ + RV_FILE_ERROR_CONFLICT, RV_FILE_ERROR_DENIED, RV_FILE_ERROR_IO, RV_FILE_TYPE_OTHER, + RV_FILE_TYPE_SYMLINK, +}; +use serde_json::json; + +use super::{Lent, contained, describe, raw_stat}; +use crate::fakes::*; + +/// The files every call below reads. +const FILES: &[(&str, &str)] = &[("/notes/a.md", "hello"), ("/notes/b.md", "")]; + +pack_fake!( + reader, + {}, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + add_tool(call, "stat"); + add_tool(call, "list"); + add_tool(call, "read"); + add_tool(call, "tail"); + add_tool(call, "past") + }, + step: |call| { + let args = step_args(call); + // The arguments are one JSON string, the path. + let path: String = serde_json::from_str(&text(args.arguments)).unwrap_or_default(); + let said = match text(args.tool).as_str() { + "stat" => format!("{:?}", stat_of(call, &path)), + "list" => format!("{:?}", list_of(call, &path)), + "read" => format!("{:?}", read_of(call, &path, (0, u64::MAX))), + "tail" => format!("{:?}", read_of(call, &path, (3, 100))), + _ => format!("{:?}", read_of(call, &path, (u64::MAX, u64::MAX))), + }; + finish(call, RV_RESULT_TRUSTED, &said) + } +); + +/// What the `reader` Pack's tool `tool` says of `path` over [`FILES`]. +fn said(tool: &str, path: &str) -> String { + let pack = made(reader::entry); + let call = seeded_call(tool, FILES); + block_on(pack.call(call.context(), json!(path))) + .expect("the call succeeds") + .text() + .to_owned() +} + +/// The text a fake's helper formats for a failure of `kind` that says +/// `text`. +fn failed(kind: u32, text: &str) -> String { + format!("Err(({kind}, {text:?}))") +} + +#[test] +fn file_stat_says_what_a_path_names() { + assert_eq!( + said("stat", "/notes/a.md"), + format!("Ok(({RV_FILE_TYPE_FILE}, 5))") + ); + assert!( + said("stat", "/notes").starts_with(&format!("Ok(({RV_FILE_TYPE_DIRECTORY}, ")), + "{}", + said("stat", "/notes") + ); +} + +#[test] +fn file_list_hands_back_each_entry_with_its_name_and_metadata() { + assert_eq!( + said("list", "/notes"), + format!("Ok([(\"a.md\", {RV_FILE_TYPE_FILE}, 5), (\"b.md\", {RV_FILE_TYPE_FILE}, 0)])") + ); +} + +#[test] +fn file_read_hands_back_the_whole_file_or_the_bytes_inside_a_range() { + assert_eq!(said("read", "/notes/a.md"), format!("Ok({:?})", b"hello")); + assert_eq!(said("read", "/notes/b.md"), "Ok([])"); + assert_eq!(said("tail", "/notes/a.md"), format!("Ok({:?})", b"lo")); + assert_eq!( + said("past", "/notes/a.md"), + "Ok([])", + "the largest offset is past the end, not a failure" + ); +} + +#[test] +fn a_path_without_a_leading_slash_is_taken_from_the_root() { + assert_eq!(said("read", "notes/a.md"), format!("Ok({:?})", b"hello")); +} + +#[test] +fn a_failed_file_function_fills_the_error_and_leaves_the_call_running() { + assert_eq!( + said("stat", "/absent"), + failed(RV_FILE_ERROR_NOT_FOUND, "not found: /absent") + ); + assert_eq!( + said("list", "/notes/a.md"), + failed( + RV_FILE_ERROR_NOT_A_DIRECTORY, + "not a directory: /notes/a.md" + ) + ); + assert_eq!( + said("read", "/notes"), + failed(RV_FILE_ERROR_IS_A_DIRECTORY, "is a directory: /notes") + ); + assert_eq!( + said("stat", ""), + failed(RV_FILE_ERROR_OTHER, "path is empty") + ); +} + +#[test] +fn a_path_of_exactly_4096_bytes_is_within_the_bound() { + let longest = format!("/{}", "a".repeat(4095)); + assert_eq!( + said("stat", &longest), + failed(RV_FILE_ERROR_NOT_FOUND, &format!("not found: {longest}")) + ); +} + +pack_fake!( + twice, + {}, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + add_tool(call, "twice") + }, + step: |call| { + let read = read_of(call, "/notes/a.md", (0, u64::MAX)); + if tool_call(call).is_null() { + set_tool_call(call, instance(0x300)); + return if read.is_ok() { RV_PENDING } else { ODD_STATUS }; + } + finish(call, RV_RESULT_TRUSTED, &format!("{read:?}")) + } +); + +#[test] +fn each_step_of_a_call_reads_through_its_own_ctx() { + let pack = made(twice::entry); + let call = seeded_call("twice", FILES); + let output = block_on(pack.call(call.context(), json!({}))).expect("the call succeeds"); + assert_eq!(output.text(), format!("Ok({:?})", b"hello")); +} + +#[test] +fn each_kind_of_node_keeps_its_number_and_any_other_kind_is_other() { + for (file_type, raw) in [ + (FileType::File, RV_FILE_TYPE_FILE), + (FileType::Directory, RV_FILE_TYPE_DIRECTORY), + (FileType::Symlink, RV_FILE_TYPE_SYMLINK), + (FileType::Fifo, RV_FILE_TYPE_OTHER), + (FileType::Socket, RV_FILE_TYPE_OTHER), + (FileType::CharDevice, RV_FILE_TYPE_OTHER), + (FileType::BlockDevice, RV_FILE_TYPE_OTHER), + ] { + let stat = raw_stat(&Stat::new(file_type, 7)); + assert_eq!( + (stat.file_type, stat.reserved, stat.len), + (raw, 0, 7), + "{file_type:?}" + ); + } +} + +#[test] +fn a_panic_inside_a_file_operation_is_a_failure_and_never_an_unwind() { + let caught: Result<(), VfsError> = contained(|| panic!("the policy broke")); + let failure = caught.expect_err("the panic is a failure"); + assert_eq!( + describe(&failure), + ( + RV_FILE_ERROR_OTHER, + "the file operation panicked".to_owned() + ) + ); + assert_eq!(contained(|| Ok(7)), Ok(7), "any other outcome passes"); +} + +#[test] +fn each_error_reads_as_its_kind_and_a_text_a_model_can_read() { + let path = || "/x".to_owned(); + for (error, kind, text) in [ + ( + VfsError::NotFound { path: path() }, + RV_FILE_ERROR_NOT_FOUND, + "not found: /x", + ), + ( + VfsError::NotADirectory { path: path() }, + RV_FILE_ERROR_NOT_A_DIRECTORY, + "not a directory: /x", + ), + ( + VfsError::IsADirectory { path: path() }, + RV_FILE_ERROR_IS_A_DIRECTORY, + "is a directory: /x", + ), + ( + VfsError::PermissionDenied { + path: path(), + reason: "the mode allows only markdown".to_owned(), + }, + RV_FILE_ERROR_DENIED, + "the mode allows only markdown", + ), + ( + VfsError::ApprovalRequired { + path: path(), + reason: "reading /x needs approval".to_owned(), + }, + RV_FILE_ERROR_DENIED, + "reading /x needs approval", + ), + ( + VfsError::Conflict { + path: path(), + detail: "a write by another call".to_owned(), + }, + RV_FILE_ERROR_CONFLICT, + "another tool call of the run changed /x, or is changing it", + ), + ( + VfsError::Io { + path: path(), + kind: io::ErrorKind::PermissionDenied, + raw_os_error: Some(13), + message: "access denied".to_owned(), + }, + RV_FILE_ERROR_IO, + "permission denied: access denied", + ), + ( + VfsError::InvalidPath { + path: String::new(), + reason: PathReason::Empty, + }, + RV_FILE_ERROR_OTHER, + "path is empty", + ), + ( + // The path holds the `": "` that ends a label. + VfsError::InvalidPath { + path: "a: b/../..".to_owned(), + reason: PathReason::Traversal, + }, + RV_FILE_ERROR_OTHER, + "path contains a traversal segment", + ), + ( + VfsError::NotUtf8 { + path: path(), + valid_up_to: 3, + }, + RV_FILE_ERROR_OTHER, + "/x holds a name or contents that are not UTF-8", + ), + ( + VfsError::ScopeEnded { path: path() }, + RV_FILE_ERROR_OTHER, + "the run or tool call that owned this access has ended, so /x can no longer be \ + touched through it", + ), + ( + VfsError::Backend { + message: "the backend is gone".to_owned(), + }, + RV_FILE_ERROR_OTHER, + "the backend is gone", + ), + ( + VfsError::NoStore, + RV_FILE_ERROR_OTHER, + "the handle declares no store", + ), + ] { + assert_eq!(describe(&error), (kind, text.to_owned()), "{error:?}"); + } +} + +#[test] +fn each_file_function_keeps_only_what_it_hands_back() { + let mut lent = Lent::default(); + let bytes = lent.bytes(b"hello".to_vec()); + assert_eq!((bytes.len, lent.bytes.len()), (5, 5)); + + let failure = lent.error(&VfsError::NotFound { + path: "/x".to_owned(), + }); + assert_eq!( + (failure.kind, failure.reserved, text(failure.text)), + (RV_FILE_ERROR_NOT_FOUND, 0, "not found: /x".to_owned()) + ); + assert!(lent.bytes.is_empty(), "the bytes of the read are gone"); + + let listed = lent.entries(vec![runvil_plugin::Entry::new( + "a.md", + Stat::new(FileType::File, 5), + )]); + assert_eq!((listed.len, lent.entries.len()), (1, 1)); + assert!(lent.text.is_empty(), "the text of the failure is gone"); +} + +/// The message of a call that failed because its Plugin misused it. +macro_rules! misuse_reason { + ($detail:expr) => { + concat!("the tool's Plugin misused the tool call: ", $detail) + }; +} + +/// Defines a test whose fake step runs `$step`, which calls a file +/// function wrongly and returns the step's status. The call must fail as +/// a `ToolOther` error that names the misuse `$detail`. +macro_rules! file_misuse_test { + ($test:ident, $detail:expr, |$call:ident| $step:expr) => { + #[test] + fn $test() { + pack_fake!( + plug, + {}, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + add_tool(call, "tool") + }, + step: |$call| $step + ); + let pack = made(plug::entry); + let call = seeded_call("tool", FILES); + let failure = block_on(pack.call(call.context(), json!({}))) + .expect_err("the call fails"); + assert_eq!(failure.code, runvil_plugin::ErrorCode::ToolOther); + assert_eq!(failure.message, misuse_reason!($detail)); + } + }; +} + +file_misuse_test!( + a_path_over_4096_bytes_is_misuse, + "the path is longer than 4096 bytes", + |call| { + let (mut stat, mut error) = (unset_stat(), unset_error()); + // The length is refused before any byte is read, so the text + // behind it need not be that long. + let long = RvStr { + ptr: "x".as_ptr().cast(), + len: 4097, + }; + stat_as( + call, + step_args(call).ctx, + long, + &raw mut stat, + &raw mut error, + ) + } +); + +file_misuse_test!( + a_path_with_a_null_pointer_and_a_length_is_misuse, + "the path has a NULL pointer and a nonzero length", + |call| { + let (mut stat, mut error) = (unset_stat(), unset_error()); + let null = RvStr { + ptr: ptr::null(), + len: 4, + }; + stat_as( + call, + step_args(call).ctx, + null, + &raw mut stat, + &raw mut error, + ) + } +); + +file_misuse_test!( + a_path_that_is_not_utf8_is_misuse, + "the path is not UTF-8", + |call| { + let (mut stat, mut error) = (unset_stat(), unset_error()); + let path = rv_str_bytes(&[0xff, 0xfe]); + stat_as( + call, + step_args(call).ctx, + path, + &raw mut stat, + &raw mut error, + ) + } +); + +file_misuse_test!( + a_file_function_through_another_ctx_is_misuse, + "a tool call function was handed a ctx other than the open RV_TOOL_CALL step's", + |call| { + let (mut stat, mut error) = (unset_stat(), unset_error()); + stat_as( + call, + ptr::null_mut(), + rv_str("/notes/a.md"), + &raw mut stat, + &raw mut error, + ) + } +); + +file_misuse_test!( + a_null_stat_is_misuse, + "file_stat was handed a NULL stat pointer", + |call| { + let mut error = unset_error(); + stat_as( + call, + step_args(call).ctx, + rv_str("/notes/a.md"), + ptr::null_mut(), + &raw mut error, + ) + } +); + +file_misuse_test!( + a_misaligned_stat_is_misuse, + "file_stat was handed a misaligned stat pointer", + |call| { + let mut error = unset_error(); + let odd = ptr::without_provenance_mut(1); + stat_as( + call, + step_args(call).ctx, + rv_str("/notes/a.md"), + odd, + &raw mut error, + ) + } +); + +file_misuse_test!( + a_null_error_of_a_stat_is_misuse, + "file_stat was handed a NULL error pointer", + |call| { + let mut stat = unset_stat(); + stat_as( + call, + step_args(call).ctx, + rv_str("/notes/a.md"), + &raw mut stat, + ptr::null_mut(), + ) + } +); + +file_misuse_test!( + a_misaligned_error_of_a_stat_is_misuse, + "file_stat was handed a misaligned error pointer", + |call| { + let mut stat = unset_stat(); + let odd = ptr::without_provenance_mut(1); + stat_as( + call, + step_args(call).ctx, + rv_str("/notes/a.md"), + &raw mut stat, + odd, + ) + } +); + +file_misuse_test!( + a_null_entries_is_misuse, + "file_list was handed a NULL entries pointer", + |call| { + let mut error = unset_error(); + list_as( + call, + step_args(call).ctx, + rv_str("/notes"), + ptr::null_mut(), + &raw mut error, + ) + } +); + +file_misuse_test!( + a_misaligned_entries_is_misuse, + "file_list was handed a misaligned entries pointer", + |call| { + let mut error = unset_error(); + let odd = ptr::without_provenance_mut(1); + list_as( + call, + step_args(call).ctx, + rv_str("/notes"), + odd, + &raw mut error, + ) + } +); + +file_misuse_test!( + a_null_error_of_a_list_is_misuse, + "file_list was handed a NULL error pointer", + |call| { + let mut entries = unset_entries(); + list_as( + call, + step_args(call).ctx, + rv_str("/notes"), + &raw mut entries, + ptr::null_mut(), + ) + } +); + +file_misuse_test!( + a_misaligned_error_of_a_list_is_misuse, + "file_list was handed a misaligned error pointer", + |call| { + let mut entries = unset_entries(); + let odd = ptr::without_provenance_mut(1); + list_as( + call, + step_args(call).ctx, + rv_str("/notes"), + &raw mut entries, + odd, + ) + } +); + +file_misuse_test!( + a_null_bytes_is_misuse, + "file_read was handed a NULL bytes pointer", + |call| { + let mut error = unset_error(); + read_as( + call, + step_args(call).ctx, + rv_str("/notes/a.md"), + (0, 1), + ptr::null_mut(), + &raw mut error, + ) + } +); + +file_misuse_test!( + a_misaligned_bytes_is_misuse, + "file_read was handed a misaligned bytes pointer", + |call| { + let mut error = unset_error(); + let odd = ptr::without_provenance_mut(1); + read_as( + call, + step_args(call).ctx, + rv_str("/notes/a.md"), + (0, 1), + odd, + &raw mut error, + ) + } +); + +file_misuse_test!( + a_null_error_of_a_read_is_misuse, + "file_read was handed a NULL error pointer", + |call| { + let mut bytes = unset_bytes(); + read_as( + call, + step_args(call).ctx, + rv_str("/notes/a.md"), + (0, 1), + &raw mut bytes, + ptr::null_mut(), + ) + } +); + +file_misuse_test!( + a_misaligned_error_of_a_read_is_misuse, + "file_read was handed a misaligned error pointer", + |call| { + let mut bytes = unset_bytes(); + let odd = ptr::without_provenance_mut(1); + read_as( + call, + step_args(call).ctx, + rv_str("/notes/a.md"), + (0, 1), + &raw mut bytes, + odd, + ) + } +); + +#[test] +fn a_misuse_writes_neither_the_result_nor_the_error() { + pack_fake!( + plug, + { + /// The file type and the error kind after a refused call. + pub(super) static LEFT: AtomicUsize = AtomicUsize::new(0); + }, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + add_tool(call, "tool") + }, + step: |call| { + let (mut stat, mut error) = (unset_stat(), unset_error()); + let status = + stat_as(call, ptr::null_mut(), rv_str("/notes/a.md"), &raw mut stat, &raw mut error); + LEFT.store((stat.file_type + error.kind) as usize, Ordering::SeqCst); + status + } + ); + let pack = made(plug::entry); + let call = seeded_call("tool", FILES); + block_on(pack.call(call.context(), json!({}))).expect_err("the call fails"); + assert_eq!(plug::LEFT.load(Ordering::SeqCst), 18, "both still hold 9"); +} + +#[test] +fn a_file_function_called_while_a_pack_instance_is_made_is_misuse() { + pack_fake!( + plug, + {}, + new: |call| { + let (mut stat, mut error) = (unset_stat(), unset_error()); + // SAFETY: the function refuses a call that is not a step + // before it reads or writes through any pointer. + unsafe { + crate::file::file_stat( + pack_args(call).ctx, + rv_str("/notes/a.md"), + &raw mut stat, + &raw mut error, + ) + } + }, + step: |_call| RV_OK + ); + let failure = new_pack(plug::entry) + .err() + .expect("the instance is not made"); + assert_eq!( + failure.message, + "the Plugin fake misused the Pack functions: a tool call function was called outside \ + RV_TOOL_CALL" + ); +} + +#[test] +fn a_late_file_function_through_a_stashed_ctx_is_misuse_and_writes_nothing() { + pack_fake!( + plug, + { + pub(super) static STEP_CTX: AtomicPtr = AtomicPtr::new(ptr::null_mut()); + }, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + add_tool(call, "tool") + }, + step: |call| { + STEP_CTX.store(step_args(call).ctx, Ordering::SeqCst); + finish(call, RV_RESULT_TRUSTED, "done") + } + ); + let pack = made(plug::entry); + let call = seeded_call("tool", FILES); + block_on(pack.call(call.context(), json!({}))).expect("the call succeeds"); + let stashed = plug::STEP_CTX.load(Ordering::SeqCst); + let (mut stat, mut error) = (unset_stat(), unset_error()); + // SAFETY: the loader's function compares a `ctx` with the open call's + // before it reads through it, and no call is open on this thread. + let status = unsafe { + crate::file::file_stat( + stashed, + rv_str("/notes/a.md"), + &raw mut stat, + &raw mut error, + ) + }; + assert_eq!(status, RV_ERR_MISUSE); + assert_eq!((stat.file_type, error.kind), (9, 9)); +} diff --git a/crates/harness-internal/plugin-loader/src/file.rs b/crates/harness-internal/plugin-loader/src/file.rs new file mode 100644 index 000000000..6f92ad959 --- /dev/null +++ b/crates/harness-internal/plugin-loader/src/file.rs @@ -0,0 +1,279 @@ +//! The file functions an `RvToolCallArgs` carries: `file_stat`, +//! `file_list`, and `file_read`, each answered from the access of the +//! step's tool call, so the run's policy applies to every read, and +//! [`Lent`], what the last of them handed back. + +use std::ffi::c_void; +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use runvil_plugin::{Entry, FileType, Stat, VfsError}; +use runvil_plugin_sys::{ + RV_ERR_FILE, RV_FILE_ERROR_CONFLICT, RV_FILE_ERROR_DENIED, RV_FILE_ERROR_IO, + RV_FILE_ERROR_IS_A_DIRECTORY, RV_FILE_ERROR_NOT_A_DIRECTORY, RV_FILE_ERROR_NOT_FOUND, + RV_FILE_ERROR_OTHER, RV_FILE_TYPE_DIRECTORY, RV_FILE_TYPE_FILE, RV_FILE_TYPE_OTHER, + RV_FILE_TYPE_SYMLINK, RV_MAX_PATH, RV_OK, RvBytes, RvDirEntries, RvDirEntry, RvFileError, + RvFileErrorKind, RvStat, RvStatus, RvStr, +}; + +use crate::session::{Field, Out, answer_file, field, out, rv_str}; + +#[cfg(test)] +#[path = "file-tests.rs"] +mod tests; + +const PATH: Field = field!("the path", 4096, RV_MAX_PATH); +const STAT: Out = out!("file_stat", "stat"); +const STAT_ERROR: Out = out!("file_stat", "error"); +const ENTRIES: Out = out!("file_list", "entries"); +const LIST_ERROR: Out = out!("file_list", "error"); +const BYTES: Out = out!("file_read", "bytes"); +const READ_ERROR: Out = out!("file_read", "error"); + +/// What a step's last file function handed the Plugin. The contract +/// keeps it valid until the step's next file function or the end of the +/// step, so each file function replaces it, and the step's end frees it. +#[derive(Default)] +pub(crate) struct Lent { + /// The bytes of a read. + bytes: Vec, + /// The names the entries of a listing point at, kept only so that + /// they stay alive. + _names: Vec, + /// The entries of a listing. + entries: Vec, + /// The text of a failure. + text: String, +} + +impl Lent { + /// Keeps `bytes` and returns them as the contract hands them back. + fn bytes(&mut self, bytes: Vec) -> RvBytes { + *self = Lent { + bytes, + ..Lent::default() + }; + RvBytes { + ptr: self.bytes.as_ptr(), + len: self.bytes.len(), + } + } + + /// Keeps `entries` and returns them as the contract hands them back. + fn entries(&mut self, entries: Vec) -> RvDirEntries { + let stats: Vec = entries.iter().map(|entry| raw_stat(&entry.stat)).collect(); + let names: Vec = entries.into_iter().map(|entry| entry.name).collect(); + // Each name's bytes sit in its own allocation, so moving `names` + // into `self` leaves the pointers below valid. + let entries = names + .iter() + .zip(stats) + .map(|(name, stat)| RvDirEntry { + name: rv_str(name), + stat, + }) + .collect(); + *self = Lent { + _names: names, + entries, + ..Lent::default() + }; + RvDirEntries { + ptr: self.entries.as_ptr(), + len: self.entries.len(), + } + } + + /// Keeps the text of `error` and returns the failure as the contract + /// hands it back. + fn error(&mut self, error: &VfsError) -> RvFileError { + let (kind, text) = describe(error); + *self = Lent { + text, + ..Lent::default() + }; + RvFileError { + kind, + reserved: 0, + text: rv_str(&self.text), + } + } +} + +/// `stat` as the contract hands it back. A kind of node the contract +/// does not name is `RV_FILE_TYPE_OTHER`. +fn raw_stat(stat: &Stat) -> RvStat { + RvStat { + file_type: match stat.file_type { + FileType::File => RV_FILE_TYPE_FILE, + FileType::Directory => RV_FILE_TYPE_DIRECTORY, + FileType::Symlink => RV_FILE_TYPE_SYMLINK, + _ => RV_FILE_TYPE_OTHER, + }, + reserved: 0, + len: stat.size, + } +} + +/// The contract's kind for `error`, and the text a model reads. +/// +/// A missing path, a path that is not a directory, and a path that is one +/// keep the error's own sentence. A policy denial carries the policy's +/// own reason. A conflict names the path and not the two identities that +/// claimed it, which mean nothing outside the Harness. An I/O failure +/// carries the kind of the failure and the system's description. Of the +/// errors the contract has no kind for, a refused path carries the rule +/// it broke, and every other one its sentence without the leading label. +fn describe(error: &VfsError) -> (RvFileErrorKind, String) { + match error { + VfsError::NotFound { .. } => (RV_FILE_ERROR_NOT_FOUND, error.to_string()), + VfsError::NotADirectory { .. } => (RV_FILE_ERROR_NOT_A_DIRECTORY, error.to_string()), + VfsError::IsADirectory { .. } => (RV_FILE_ERROR_IS_A_DIRECTORY, error.to_string()), + VfsError::PermissionDenied { reason, .. } | VfsError::ApprovalRequired { reason, .. } => { + (RV_FILE_ERROR_DENIED, reason.clone()) + } + VfsError::Conflict { path, .. } => ( + RV_FILE_ERROR_CONFLICT, + format!("another tool call of the run changed {path}, or is changing it"), + ), + VfsError::Io { kind, message, .. } => (RV_FILE_ERROR_IO, format!("{kind}: {message}")), + // The path is quoted in the sentence and may hold `": "` itself, + // so the rule is taken from the error, not cut out of the text. + VfsError::InvalidPath { reason, .. } => (RV_FILE_ERROR_OTHER, reason.to_string()), + VfsError::NotUtf8 { path, .. } => ( + RV_FILE_ERROR_OTHER, + format!("{path} holds a name or contents that are not UTF-8"), + ), + other => (RV_FILE_ERROR_OTHER, without_label(&other.to_string())), + } +} + +/// Runs one operation of a tool call's access, and turns a panic inside +/// it into a failure. The operation runs under a C function the Plugin +/// called, which a panic must not unwind through, and the policy and the +/// backend behind an access are the Host's own code. +fn contained(operation: impl FnOnce() -> Result) -> Result { + catch_unwind(AssertUnwindSafe(operation)).unwrap_or_else(|_| { + Err(VfsError::Backend { + message: "the file operation panicked".to_owned(), + }) + }) +} + +/// `text` without its leading label: the text after the first `": "`, or +/// the whole text when it has none. +fn without_label(text: &str) -> String { + text.split_once(": ") + .map_or(text, |(_, rest)| rest) + .to_owned() +} + +/// Says what `path` names; returns RV_OK with `stat` filled, RV_ERR_FILE +/// with `error` filled, or RV_ERR_MISUSE. +pub(crate) unsafe extern "C" fn file_stat( + ctx: *mut c_void, + path: RvStr, + stat: *mut RvStat, + error: *mut RvFileError, +) -> RvStatus { + answer_file(ctx, |access, lent| { + let stat = STAT.check(stat)?; + let error = STAT_ERROR.check(error)?; + // SAFETY: the contract makes `path` valid for its length during + // this call. + let path = unsafe { PATH.read(path) }?; + Ok(match contained(|| access.stat(&path)) { + Ok(found) => { + *lent = Lent::default(); + // SAFETY: `stat` is not NULL and is aligned, and the + // contract makes it a struct the Plugin owns. + unsafe { stat.write(raw_stat(&found)) }; + RV_OK + } + // SAFETY: as above, for `error`. + Err(failure) => unsafe { fail(lent, error, &failure) }, + }) + }) +} + +/// Lists the directory at `path`; returns RV_OK with `entries` filled, +/// RV_ERR_FILE with `error` filled, or RV_ERR_MISUSE. +pub(crate) unsafe extern "C" fn file_list( + ctx: *mut c_void, + path: RvStr, + entries: *mut RvDirEntries, + error: *mut RvFileError, +) -> RvStatus { + answer_file(ctx, |access, lent| { + let entries = ENTRIES.check(entries)?; + let error = LIST_ERROR.check(error)?; + // SAFETY: the contract makes `path` valid for its length during + // this call. + let path = unsafe { PATH.read(path) }?; + Ok(match contained(|| access.list(&path)) { + Ok(listed) => { + let listed = lent.entries(listed); + // SAFETY: `entries` is not NULL and is aligned, and the + // contract makes it a struct the Plugin owns. + unsafe { entries.write(listed) }; + RV_OK + } + // SAFETY: as above, for `error`. + Err(failure) => unsafe { fail(lent, error, &failure) }, + }) + }) +} + +/// Reads up to `len` bytes of the file at `path` from the byte `offset` +/// on; returns RV_OK with `bytes` filled, RV_ERR_FILE with `error` filled, +/// or RV_ERR_MISUSE. +pub(crate) unsafe extern "C" fn file_read( + ctx: *mut c_void, + path: RvStr, + offset: u64, + len: u64, + bytes: *mut RvBytes, + error: *mut RvFileError, +) -> RvStatus { + answer_file(ctx, |access, lent| { + let bytes = BYTES.check(bytes)?; + let error = READ_ERROR.check(error)?; + // SAFETY: the contract makes `path` valid for its length during + // this call. + let path = unsafe { PATH.read(path) }?; + // No read returns more than memory can hold, and a backend that + // slices a whole file refuses a length it cannot address. + let len = usize::try_from(len).unwrap_or(usize::MAX); + // A real file cannot be positioned past this offset, and no file + // is that long, so the read finds the end and returns no bytes. + let offset = offset.min(i64::MAX.unsigned_abs()); + let wanted = u64::try_from(len).unwrap_or(u64::MAX); + Ok( + match contained(|| access.read_range(&path, offset, wanted)) { + Ok(mut read) => { + // The contract promises at most `len` bytes, whatever + // the backend returned. + read.truncate(len); + let read = lent.bytes(read); + // SAFETY: `bytes` is not NULL and is aligned, and the + // contract makes it a struct the Plugin owns. + unsafe { bytes.write(read) }; + RV_OK + } + // SAFETY: as above, for `error`. + Err(failure) => unsafe { fail(lent, error, &failure) }, + }, + ) + }) +} + +/// Hands `failure` back through `error`, with its text kept in `lent`, +/// and returns RV_ERR_FILE. +/// +/// # Safety +/// `error` must be valid for a write of an `RvFileError`. +unsafe fn fail(lent: &mut Lent, error: *mut RvFileError, failure: &VfsError) -> RvStatus { + let raw = lent.error(failure); + // SAFETY: the caller makes `error` valid for the write. + unsafe { error.write(raw) }; + RV_ERR_FILE +} diff --git a/crates/harness-internal/plugin-loader/src/host-tests.rs b/crates/harness-internal/plugin-loader/src/host-tests.rs index 4d16cea32..3faf08761 100644 --- a/crates/harness-internal/plugin-loader/src/host-tests.rs +++ b/crates/harness-internal/plugin-loader/src/host-tests.rs @@ -8,10 +8,17 @@ use std::sync::atomic::Ordering; use crate::fakes::*; use crate::host::HOST; -/// What a name that is not one Pack id segment is reported as. +/// What a name that is not one name segment is reported as. macro_rules! bad_name { () => { - "the name is not one Pack id segment, such as `acme-url`" + "the name is not one name segment, such as `acme-url`" + }; +} + +/// What a Pack name that is not a vendor and a Pack id is reported as. +macro_rules! bad_pack_name { + () => { + "the Pack's name is not a vendor and a Pack id joined by `/`, such as `acme/url`" }; } @@ -458,10 +465,162 @@ fn the_load_call_carries_the_contract_fields() { } #[test] -fn the_table_reports_version_0_2_in_16_bytes() { +fn the_table_reports_version_0_4_in_16_bytes() { assert_eq!(HOST.api_major, 0); - assert_eq!(HOST.api_minor, 2); + assert_eq!(HOST.api_minor, 4); assert_eq!(HOST.reserved, 0); assert_eq!(HOST.size, 16); assert_eq!(usize::try_from(HOST.size), Ok(size_of::())); } + +/// Defines a test whose fake load describes itself, runs `$misuse`, an +/// `insert_pack` call that must answer RV_ERR_MISUSE, and then returns +/// RV_OK. The load must fail with `$detail`, and a failed load is never +/// unloaded. +macro_rules! pack_misuse_test { + ($test:ident, $detail:expr, |$call:ident| $misuse:expr) => { + misuse_test!($test, $detail, |$call| { + describe($call, "acme-url", "1.0.0"); + $misuse + }); + }; +} + +pack_misuse_test!( + a_pack_name_without_a_vendor_is_misuse, + bad_pack_name!(), + |call| insert_pack(call, "url", "") +); + +pack_misuse_test!( + a_pack_name_with_three_segments_is_misuse, + bad_pack_name!(), + |call| insert_pack(call, "acme/url/extra", "") +); + +pack_misuse_test!( + a_pack_name_with_an_empty_segment_is_misuse, + bad_pack_name!(), + |call| insert_pack(call, "acme/", "") +); + +pack_misuse_test!( + a_pack_name_with_an_uppercase_letter_is_misuse, + bad_pack_name!(), + |call| insert_pack(call, "acme/Url", "") +); + +pack_misuse_test!( + a_pack_name_with_a_segment_over_64_bytes_is_misuse, + bad_pack_name!(), + |call| insert_pack(call, &format!("acme/{}", "a".repeat(65)), "") +); + +pack_misuse_test!( + a_pack_name_over_129_bytes_is_misuse, + "the Pack's name is longer than 129 bytes", + |call| insert_pack(call, &"a".repeat(130), "") +); + +pack_misuse_test!( + a_pack_name_that_is_not_utf8_is_misuse, + "the Pack's name is not UTF-8", + |call| insert_pack_info(call, &pack_info(rv_str_bytes(&[0xff, 0xfe]), rv_str(""))) +); + +pack_misuse_test!( + a_pack_inserted_twice_is_misuse, + "two Packs of one Plugin share a name", + |call| { + insert_pack(call, "acme/url", ""); + insert_pack(call, "acme/url", "again") + } +); + +pack_misuse_test!( + a_pack_description_over_1024_bytes_is_misuse, + "the Pack's description is longer than 1024 bytes", + |call| insert_pack(call, "acme/url", &"d".repeat(1025)) +); + +pack_misuse_test!( + a_pack_description_with_a_control_character_is_misuse, + "the Pack's description has a control character", + |call| insert_pack(call, "acme/url", "two\nlines") +); + +pack_misuse_test!( + a_short_pack_info_size_is_misuse, + "the info's size is shorter than RvPackInfo", + |call| insert_pack_info( + call, + &RvPackInfo { + size: 8, + ..pack_info(rv_str("acme/url"), rv_str("")) + } + ) +); + +pack_misuse_test!( + a_nonzero_reserved_field_in_a_pack_info_is_misuse, + "the info's reserved field is not zero", + |call| insert_pack_info( + call, + &RvPackInfo { + reserved: 1, + ..pack_info(rv_str("acme/url"), rv_str("")) + } + ) +); + +pack_misuse_test!( + a_null_pack_info_is_misuse, + "insert_pack was handed a NULL info", + |call| { + // SAFETY: the loader's function accepts a NULL info and answers + // misuse; the call's `ctx` is the open load's. + unsafe { crate::host::insert_pack(ctx(call), ptr::null()) } + } +); + +pack_misuse_test!( + a_pack_function_called_during_a_load_is_misuse, + "a Pack function was called outside RV_PACK_NEW", + |call| { + let tool = tool_info("fetch"); + // SAFETY: the loader's function reads through `ctx` only when it + // is the open call's, and answers misuse for a call of another + // verb before it reads `tool`. + unsafe { crate::pack::add_tool(ctx(call), &raw const tool) } + } +); + +pack_misuse_test!( + finish_called_during_a_load_is_misuse, + "a tool call function was called outside RV_TOOL_CALL", + |call| { + let result = result(RV_RESULT_TRUSTED, rv_str("done")); + // SAFETY: as in the test above. + unsafe { crate::call::finish(ctx(call), &raw const result) } + } +); + +#[test] +fn a_pack_name_and_a_description_at_their_bounds_are_accepted() { + fake!(plug, |call| { + describe(call, "acme-url", "1.0.0"); + insert_pack( + call, + &format!("{}/{}", "v".repeat(64), "p".repeat(64)), + &"d".repeat(1024), + ) + }); + let mut packs = runvil_plugin::PackRegistry::new(); + let _plugin = crate::plugin::insert_plugin(&mut packs, plug::entry).expect("the Plugin loads"); + let fqn = format!("{}/{}", "v".repeat(64), "p".repeat(64)); + let description = packs + .list() + .find(|(info, _)| info.fqn == fqn) + .map(|(info, _)| info.description); + assert_eq!(description, Some("d".repeat(1024).as_str())); +} diff --git a/crates/harness-internal/plugin-loader/src/host.rs b/crates/harness-internal/plugin-loader/src/host.rs index 3432834cd..8f2868dc2 100644 --- a/crates/harness-internal/plugin-loader/src/host.rs +++ b/crates/harness-internal/plugin-loader/src/host.rs @@ -1,21 +1,22 @@ -//! The `RvHost` table every call hands a Plugin, the load functions each -//! RV_PLUGIN_LOAD's `RvLoadArgs` carries, and the two verb senders. Each -//! load's `ctx` is a heap allocation holding that load's session, and a -//! thread-local slot names the one `ctx` the load functions may answer. +//! The `RvHost` table every call hands a Plugin, the functions each +//! RV_PLUGIN_LOAD's `RvLoadArgs` carries, and the senders of +//! RV_PLUGIN_LOAD and RV_PLUGIN_UNLOAD. -use std::cell::Cell; use std::ffi::c_void; -use std::mem::size_of; -use std::ptr::{self, NonNull}; -use std::slice; +use std::{mem, ptr}; use runvil_plugin::{Error, PackId}; use runvil_plugin_sys::{ - PluginEntry, RV_API_MAJOR, RV_API_MINOR, RV_ERR_MISUSE, RV_ERR_REFUSED, RV_OK, RV_PLUGIN_LOAD, - RV_PLUGIN_UNLOAD, RvCall, RvHost, RvLoadArgs, RvPluginInfo, RvStatus, RvStr, + PluginEntry, RV_API_MAJOR, RV_API_MINOR, RV_MAX_LINE, RV_MAX_NAME, RV_OK, RV_PLUGIN_LOAD, + RV_PLUGIN_UNLOAD, RvCall, RvHost, RvLoadArgs, RvPackInfo, RvPluginInfo, RvStatus, RvStr, + RvVerb, }; use crate::load::make_load_error; +use crate::session::{ + self, Described, Field, InsertedPack, LOAD, RESERVED, Session, Shape, Verb, answer, field, + shape, size_field, +}; #[cfg(test)] #[path = "host-tests.rs"] @@ -29,307 +30,153 @@ pub(crate) static HOST: RvHost = RvHost { reserved: 0, }; -thread_local! { - /// The session of the RV_PLUGIN_LOAD call running on this thread, which - /// its `ctx` points at, or `None` when no load is running. A `Cell`, so - /// no borrow is ever held and no borrow check can fail: `answer` takes - /// it out while it works, and `send_load` and `send_unload` swap it. - static OPEN: Cell>> = const { Cell::new(None) }; -} - -/// What one RV_PLUGIN_LOAD has reported so far. -#[derive(Default)] -struct Session { - described: Option, - refusal: Option, - misuse: Option<&'static str>, -} - -/// The `ctx` of one RV_PLUGIN_LOAD call: a heap allocation holding its -/// session. It is freed when it drops: as the rejected load returns, or, -/// for a load that succeeds, after its RV_PLUGIN_UNLOAD returns. So while a -/// Plugin is loaded, its `ctx` is the address of no other load's. -pub(crate) struct LoadCtx { - session: NonNull, -} +/// The bound on one name segment, in bytes. +pub(crate) const MAX_SEGMENT: usize = 64; -impl LoadCtx { - /// Allocates a `ctx` with an empty session. - fn new() -> LoadCtx { - LoadCtx { - session: NonNull::from(Box::leak(Box::::default())), - } - } - - /// The pointer the load's `RvLoadArgs` carries. - fn as_ctx(&self) -> *mut c_void { - self.session.as_ptr().cast() - } +const PLUGIN_INFO: Shape = shape!("describe_plugin", "info", "RvPluginInfo"); +const NAME: Field = field!("the name", 64, RV_MAX_NAME); +const VERSION: Field = field!("the version", 64, RV_MAX_NAME); - /// Takes what the load reported. Called only after `send_load` closed - /// the session, so no slot holds it and nothing else reaches it. - fn take_session(&mut self) -> Session { - // SAFETY: `session` is the live allocation `new` made, and once its - // load returned no slot holds it, so this is its only access. - std::mem::take(unsafe { self.session.as_mut() }) - } -} +const PACK_INFO: Shape = shape!("insert_pack", "info", "RvPackInfo"); +/// Two segments of 64 bytes and the `/` between them. +const PACK_NAME: Field = field!("the Pack's name", 129, 2 * RV_MAX_NAME + 1); +const PACK_DESCRIPTION: Field = field!("the Pack's description", 1024, RV_MAX_LINE); -impl Drop for LoadCtx { - fn drop(&mut self) { - // SAFETY: `session` came from `Box::leak` in `new`, and this drop - // is its only free. - drop(unsafe { Box::from_raw(self.session.as_ptr()) }); +/// An `RvCall` that sends `verb` to the Plugin instance `plugin`, with the +/// loader's `RvHost` table and nothing else set. +pub(crate) fn new_call(verb: RvVerb, plugin: *mut c_void) -> RvCall { + RvCall { + size: size_field::(), + verb, + host: &raw const HOST, + plugin, + pack_run: ptr::null_mut(), + args: ptr::null(), + tool_call: ptr::null_mut(), } } -/// The name and version a Plugin gave `describe_plugin`, copied. -pub(crate) struct Described { - pub(crate) name: String, - pub(crate) version: String, +/// Whether `name` is one name segment: a Pack id segment of at most 64 +/// bytes. +pub(crate) fn is_segment(name: &str) -> bool { + name.len() <= MAX_SEGMENT && PackId::parse(name).is_ok() } -/// One string field's bound, and what each way of breaking it is called. -struct Field { - cap: usize, - too_long: &'static str, - null_with_length: &'static str, - not_utf8: &'static str, +/// Records the Plugin's refusal to load, validated; returns +/// RV_ERR_REFUSED, or RV_ERR_MISUSE for a `ctx` it doesn't answer or a +/// second refusal. +pub(crate) unsafe extern "C" fn refuse(ctx: *mut c_void, reason: RvStr) -> RvStatus { + session::refuse(ctx, &LOAD, reason) } -const NAME: Field = Field { - cap: 64, - too_long: "the name is longer than 64 bytes", - null_with_length: "the name has a NULL pointer and a nonzero length", - not_utf8: "the name is not UTF-8", -}; - -const VERSION: Field = Field { - cap: 64, - too_long: "the version is longer than 64 bytes", - null_with_length: "the version has a NULL pointer and a nonzero length", - not_utf8: "the version is not UTF-8", -}; - -const REASON: Field = Field { - cap: 1024, - too_long: "the refusal reason is longer than 1024 bytes", - null_with_length: "the refusal reason has a NULL pointer and a nonzero length", - not_utf8: "the refusal reason is not UTF-8", -}; - -impl Field { - /// Copies the string `s` holds. The length is checked against the cap - /// before any byte is read, and `s.ptr` is read only when `s.len` is - /// nonzero, because `slice::from_raw_parts` with a NULL pointer is - /// undefined behavior even at length 0. - /// - /// # Safety - /// When `s.len` is nonzero, `s.ptr` must be valid for reads of `s.len` - /// bytes for the duration of this call. - unsafe fn read(&self, s: RvStr) -> Result { - if s.len > self.cap { - return Err(self.too_long); +/// Records the Plugin's description once, validated; returns RV_OK or +/// RV_ERR_MISUSE. +pub(crate) unsafe extern "C" fn describe_plugin( + ctx: *mut c_void, + info: *const RvPluginInfo, +) -> RvStatus { + answer(ctx, &LOAD, |session| { + if session.described.is_some() { + return Err("describe_plugin was called twice"); } - if s.len == 0 { - return Ok(String::new()); + // SAFETY: the contract makes `info` point at a readable + // `RvPluginInfo` during this call. + let info = unsafe { PLUGIN_INFO.read(info) }?; + if info.reserved != 0 { + return Err(RESERVED); } - if s.ptr.is_null() { - return Err(self.null_with_length); + // SAFETY: the contract makes each string valid for its length. + let name = unsafe { NAME.read(info.name) }?; + if !is_segment(&name) { + return Err("the name is not one name segment, such as `acme-url`"); } - // SAFETY: `s.ptr` is not NULL and `s.len` is at most the cap, and - // the caller guarantees `s.ptr` is valid for `s.len` bytes. - let bytes = unsafe { slice::from_raw_parts(s.ptr.cast::(), s.len) }; - std::str::from_utf8(bytes) - .map(str::to_owned) - .map_err(|_| self.not_utf8) - } -} - -/// `size_of::()` as the `u32` a contract struct's `size` field holds. -#[expect( - clippy::cast_possible_truncation, - reason = "the assertion bounds the size to a u32" -)] -const fn size_field() -> u32 { - assert!( - size_of::() <= u32::MAX as usize, - "a contract struct's size fits its u32 size field" - ); - size_of::() as u32 -} - -/// Answers a load function. `f` runs on the session only when this thread -/// has an RV_PLUGIN_LOAD call open and `ctx` is its `ctx`; otherwise the -/// answer is RV_ERR_MISUSE, and an open load on this thread records the -/// misuse. Nothing here reads through `ctx` itself, so a stale, freed, or -/// made-up `ctx` is only compared. -/// -/// The session is out of its slot while `f` runs, so a load function that -/// `f` somehow reached would find no open load and answer RV_ERR_MISUSE. -fn answer(ctx: *mut c_void, f: impl FnOnce(&mut Session) -> RvStatus) -> RvStatus { - OPEN.try_with(|cell| { - let open = cell.take(); - let status = match open { - Some(open) => { - let is_open_ctx = ptr::eq(open.as_ptr().cast::(), ctx); - // SAFETY: only `send_load` fills the slot, with the live - // session of the load it has open on this thread, and it - // empties the slot before that session can be freed. The - // slot is empty while this runs, so nothing else reaches - // the session meanwhile. - let session = unsafe { &mut *open.as_ptr() }; - if is_open_ctx { - f(session) - } else { - session.misuse.get_or_insert( - "a load function was handed a ctx other than the open RV_PLUGIN_LOAD call's", - ); - RV_ERR_MISUSE - } - } - None => RV_ERR_MISUSE, - }; - cell.set(open); - status - }) - .unwrap_or(RV_ERR_MISUSE) -} - -/// Records the Plugin's refusal, validated; returns RV_ERR_REFUSED, or -/// RV_ERR_MISUSE for a `ctx` it doesn't answer or a second refusal. -pub(crate) unsafe extern "C" fn refuse(ctx: *mut c_void, reason: RvStr) -> RvStatus { - answer(ctx, |session| { - if session.refusal.is_some() { - session.misuse.get_or_insert("refuse was called twice"); - return RV_ERR_MISUSE; + // SAFETY: as above. + let version = unsafe { VERSION.read(info.version) }?; + if version.is_empty() { + return Err("the version is empty"); } - // SAFETY: the contract makes `reason` valid for its length during - // this call. - match unsafe { REASON.read(reason) } { - Ok(reason) => { - session.refusal = Some(reason); - RV_ERR_REFUSED - } - Err(fault) => { - session.misuse.get_or_insert(fault); - RV_ERR_MISUSE - } + if version.chars().any(char::is_control) { + return Err("the version has a control character"); } + session.described = Some(Described { name, version }); + Ok(RV_OK) }) } -/// Records the Plugin's description once, validated; returns RV_OK or +/// Records one Pack the Plugin inserts, validated; returns RV_OK or /// RV_ERR_MISUSE. -pub(crate) unsafe extern "C" fn describe_plugin( - ctx: *mut c_void, - info: *const RvPluginInfo, -) -> RvStatus { - answer(ctx, |session| { +pub(crate) unsafe extern "C" fn insert_pack(ctx: *mut c_void, info: *const RvPackInfo) -> RvStatus { + answer(ctx, &LOAD, |session| { // SAFETY: the contract makes `info` point at a readable - // `RvPluginInfo` during this call. - match unsafe { describe(session, info) } { - Ok(()) => RV_OK, - Err(fault) => { - session.misuse.get_or_insert(fault); - RV_ERR_MISUSE - } + // `RvPackInfo` during this call. + let info = unsafe { PACK_INFO.read(info) }?; + if info.reserved != 0 { + return Err(RESERVED); + } + // SAFETY: the contract makes each string valid for its length. + let name = unsafe { PACK_NAME.read(info.name) }?; + let two_segments = name + .split_once('/') + .is_some_and(|(vendor, id)| is_segment(vendor) && is_segment(id)); + if !two_segments { + return Err( + "the Pack's name is not a vendor and a Pack id joined by `/`, such as `acme/url`", + ); } + if session.packs.iter().any(|pack| pack.name == name) { + return Err("two Packs of one Plugin share a name"); + } + // SAFETY: as above. + let description = unsafe { PACK_DESCRIPTION.read(info.description) }?; + if description.chars().any(char::is_control) { + return Err("the Pack's description has a control character"); + } + session.packs.push(InsertedPack { name, description }); + Ok(RV_OK) }) } -/// Validates `info` and copies the name and version into `session`. -/// -/// # Safety -/// When `info` is not NULL, it must be readable as an `RvPluginInfo` -/// whose `size` field says how much of it the Plugin wrote, and each -/// string in it must be valid for its length, for the duration of this -/// call. -unsafe fn describe(session: &mut Session, info: *const RvPluginInfo) -> Result<(), &'static str> { - if session.described.is_some() { - return Err("describe_plugin was called twice"); - } - if info.is_null() { - return Err("describe_plugin was handed a NULL info"); - } - if !info.is_aligned() { - return Err("describe_plugin was handed a misaligned info"); - } - // SAFETY: `info` is not NULL and is aligned, and `size` is the first - // field of a struct the Plugin wrote, so it is readable on its own. - let size = unsafe { (&raw const (*info).size).read() }; - if (size as usize) < size_of::() { - return Err("the info's size is shorter than RvPluginInfo"); - } - // SAFETY: `size` says the Plugin wrote a whole `RvPluginInfo`. - let info = unsafe { &*info }; - if info.reserved != 0 { - return Err("the info's reserved field is not zero"); - } - // SAFETY: the caller makes each string valid for its length. - let name = unsafe { NAME.read(info.name) }?; - if PackId::parse(&name).is_err() { - return Err("the name is not one Pack id segment, such as `acme-url`"); - } - // SAFETY: as above. - let version = unsafe { VERSION.read(info.version) }?; - if version.is_empty() { - return Err("the version is empty"); - } - if version.chars().any(char::is_control) { - return Err("the version has a control character"); - } - session.described = Some(Described { name, version }); - Ok(()) +/// What a successful RV_PLUGIN_LOAD left: the Plugin's description, the +/// Packs it inserted, the instance pointer it set, and the session its +/// `ctx` points at, which the caller keeps until RV_PLUGIN_UNLOAD returns. +pub(crate) struct Loading { + pub(crate) described: Described, + pub(crate) packs: Vec, + pub(crate) instance: *mut c_void, + pub(crate) ctx: Box, } /// Sends RV_PLUGIN_LOAD through `entry` with a fresh `ctx` open on this -/// thread, then closes it. On success, returns the description, the -/// instance pointer the Plugin set, and the `ctx`, which the caller keeps -/// until RV_PLUGIN_UNLOAD returns. On failure, the `ctx` is freed before -/// this returns. +/// thread, then closes it. On failure, the `ctx` is freed before this +/// returns. /// /// # Safety /// `entry` must follow the C contract in `runvil-plugin-sys`, and must not /// unwind. -pub(crate) unsafe fn send_load( - entry: PluginEntry, -) -> Result<(Described, *mut c_void, LoadCtx), Error> { - let mut ctx = LoadCtx::new(); - let args = RvLoadArgs { - size: size_field::(), - reserved: 0, - ctx: ctx.as_ctx(), - refuse, - describe_plugin, - }; - let mut call = RvCall { - size: size_field::(), - verb: RV_PLUGIN_LOAD, - host: &raw const HOST, - plugin: ptr::null_mut(), - pack_run: ptr::null_mut(), - args: (&raw const args).cast(), - }; - let call_ptr = &raw mut call; - // A load inside a load, on one thread, keeps the outer session. - let outer = OPEN.with(|cell| cell.replace(Some(ctx.session))); - // SAFETY: the caller guarantees `entry` follows the contract, and - // `call_ptr` points at a live `RvCall` that nothing else uses, whose - // `args` is live for the call. - let status = unsafe { entry(call_ptr) }; - // Everything that touches the slot while `entry` runs (`answer`, - // `send_load`, and `send_unload`) puts back what it replaced, so this - // swap closes the session opened above. - OPEN.with(|cell| cell.set(outer)); - let session = ctx.take_session(); - if let Some(reason) = session.refusal { +pub(crate) unsafe fn send_load(entry: PluginEntry) -> Result { + let mut call = new_call(RV_PLUGIN_LOAD, ptr::null_mut()); + let (status, mut session) = session::open(Verb::Load, |ctx| { + let args = RvLoadArgs { + size: size_field::(), + reserved: 0, + ctx, + refuse, + describe_plugin, + insert_pack, + }; + call.args = (&raw const args).cast(); + // SAFETY: the caller guarantees `entry` follows the contract, and + // `call` is a live `RvCall` that nothing else uses, whose `args` + // is live for the call. + unsafe { entry(&raw mut call) } + }); + if let Some(reason) = session.refusal.take() { return Err(make_load_error( "refused", format!("the Plugin refused: {reason}"), )); } - if let Some(reason) = session.misuse { + if let Some(reason) = session.misuse.take() { return Err(make_load_error( "misuse", format!("the Plugin misused the load functions: {reason}"), @@ -341,39 +188,32 @@ pub(crate) unsafe fn send_load( format!("RV_PLUGIN_LOAD returned status {status}"), )); } - match session.described { - Some(described) => Ok((described, call.plugin, ctx)), - None => Err(make_load_error( + let Some(described) = session.described.take() else { + return Err(make_load_error( "undescribed", "RV_PLUGIN_LOAD returned without describing the Plugin".to_owned(), - )), - } + )); + }; + Ok(Loading { + described, + packs: mem::take(&mut session.packs), + instance: call.plugin, + ctx: session, + }) } /// Sends RV_PLUGIN_UNLOAD through `entry` with `plugin` and NULL `args`. No -/// load is open on this thread meanwhile, so any load function the Plugin -/// calls answers RV_ERR_MISUSE. The status is ignored, because a Plugin -/// that is going away has no one to tell. +/// call is open on this thread meanwhile, so any function the Plugin calls +/// answers RV_ERR_MISUSE. The status is ignored, because a Plugin that is +/// going away has no one to tell. /// /// # Safety /// `entry` must follow the C contract and must not unwind, and `plugin` -/// must be the pointer its RV_PLUGIN_LOAD set, not yet unloaded. +/// must be the pointer its RV_PLUGIN_LOAD set, not yet unloaded, with +/// every Pack instance it made deleted. pub(crate) unsafe fn send_unload(entry: PluginEntry, plugin: *mut c_void) { - let mut call = RvCall { - size: size_field::(), - verb: RV_PLUGIN_UNLOAD, - host: &raw const HOST, - plugin, - pack_run: ptr::null_mut(), - args: ptr::null(), - }; - // An unload inside a load, on one thread, must not see the load's - // session. The last `Arc` of a Plugin may drop inside a thread-local - // destructor; `try_with` keeps such an unload going out even on a - // thread whose slot is gone, which `answer` treats as no open load. - let outer = OPEN.try_with(Cell::take).unwrap_or_default(); + let mut call = new_call(RV_PLUGIN_UNLOAD, plugin); // SAFETY: the caller guarantees `entry` follows the contract and // `plugin` is the live instance, and `call` is a live `RvCall`. - let _status = unsafe { entry(&raw mut call) }; - let _restored = OPEN.try_with(|cell| cell.set(outer)); + let _status = session::closed(|| unsafe { entry(&raw mut call) }); } diff --git a/crates/harness-internal/plugin-loader/src/lib.rs b/crates/harness-internal/plugin-loader/src/lib.rs index 04eb91d8a..429de3ac3 100644 --- a/crates/harness-internal/plugin-loader/src/lib.rs +++ b/crates/harness-internal/plugin-loader/src/lib.rs @@ -1,38 +1,62 @@ //! `harness-plugin-loader` - inserts Plugins into a Host's Pack registry, //! from dynamic libraries in a Plugin directory or linked into the Host. -//! Each Plugin loads as it is inserted. +//! Each Plugin loads as it is inserted, and each Pack it offers goes into +//! the registry with a factory that makes one instance of the Pack for +//! each run and performs that instance's tool calls, all through the +//! Plugin's entry point. //! //! ## Invariants //! //! - May depend on: `runvil-plugin` among workspace crates, and -//! `runvil-plugin-sys`, the C contract at version 0.2, from its own +//! `runvil-plugin-sys`, the C contract at version 0.4, from its own //! repository. //! - Every entry point gets the same calls, whether it came from a library //! or was linked in. The only difference is that a library is opened //! when it is inserted and closed after its Plugin is dropped. //! - Dropping a `Plugin` sends `RV_PLUGIN_UNLOAD` once, with the instance //! pointer its `RV_PLUGIN_LOAD` set, then closes its library, if it has -//! one. +//! one. Each Pack in the registry and each Pack instance of a run holds +//! the `Plugin`, so the unload comes after every `RV_PACK_DELETE`. //! - An entry point whose `RV_PLUGIN_LOAD` fails gets no //! `RV_PLUGIN_UNLOAD`, and its library is closed at once; a failing load -//! must leave nothing running. -//! - The loader sends only `RV_PLUGIN_LOAD` and `RV_PLUGIN_UNLOAD`, and the -//! load functions in an `RV_PLUGIN_LOAD` call's `RvLoadArgs` answer only -//! that call's `ctx`, on its thread, while it runs. +//! must leave nothing running. None of its Packs go into the registry. +//! - Dropping a Pack instance sends `RV_PACK_DELETE` once. A tool call +//! borrows its instance, so the delete comes after every call of the +//! instance has ended. An `RV_PACK_NEW` that fails gets no +//! `RV_PACK_DELETE`. +//! - A tool call sends one `RV_TOOL_CALL` each time its future is polled, +//! and a step that returns `RV_PENDING` wakes the future's own task. A +//! call whose last step returned `RV_PENDING` gets one `RV_TOOL_CANCEL` +//! when it fails or its future is dropped, and no other call does. +//! - The functions in a call's arguments struct answer only that call's +//! `ctx`, on its thread, while it runs. +//! - A step's file functions read through the `Access` of the step's tool +//! call and nothing else, so the run's mode and policy apply to every +//! read. What a file function hands back is freed by the step's next +//! file function, or when the step returns. //! - Each `RV_PLUGIN_LOAD` gets its own `ctx`, a loader-owned allocation //! freed when a rejected load returns or after its Plugin's -//! `RV_PLUGIN_UNLOAD` returns. -//! - Nothing the loader keeps points into a library except its entry point -//! and the Plugin's instance pointer; the name and version are copied. +//! `RV_PLUGIN_UNLOAD` returns. The `ctx` of every other call is freed +//! when the call returns. +//! - Nothing the loader keeps points into a library except its entry +//! point and the pointers the Plugin set for the loader to pass back, +//! which the loader never reads through; every text is copied. +//! - What a Plugin hands the loader is checked as `testing` in +//! `runvil-plugin-sdk` checks it: the same order, the same bounds, and +//! the same words for each misuse. #![expect( unsafe_code, reason = "opens dynamic libraries and calls C entry points" )] +mod call; #[cfg(test)] mod fakes; +mod file; mod host; mod load; +mod pack; mod plugin; +mod session; pub use plugin::{Plugin, PluginReport, PluginSource, insert_plugin, insert_plugin_dir}; diff --git a/crates/harness-internal/plugin-loader/src/load.rs b/crates/harness-internal/plugin-loader/src/load.rs index 1f2b8c805..841cefba0 100644 --- a/crates/harness-internal/plugin-loader/src/load.rs +++ b/crates/harness-internal/plugin-loader/src/load.rs @@ -9,7 +9,8 @@ use std::path::{Path, PathBuf}; use runvil_plugin::{Error, ErrorCode, ErrorFact}; use runvil_plugin_sys::PluginEntry; -use crate::host::{LoadCtx, send_unload}; +use crate::host::send_unload; +use crate::session::Session; #[cfg(test)] #[path = "load-tests.rs"] @@ -134,25 +135,38 @@ pub(crate) fn open_entry(path: &Path) -> Result<(libloading::Library, PluginEntr } /// A live Plugin instance: the entry point, the pointer RV_PLUGIN_LOAD set, -/// and the `ctx` that load was handed. Dropping it sends RV_PLUGIN_UNLOAD -/// once, and fields drop after `Drop::drop`, so the `ctx` is freed only -/// after RV_PLUGIN_UNLOAD returns. +/// and the session that load's `ctx` points at. Dropping it sends +/// RV_PLUGIN_UNLOAD once, and fields drop after `Drop::drop`, so the `ctx` +/// is freed only after RV_PLUGIN_UNLOAD returns. So while a Plugin is +/// loaded, its load's `ctx` is the address of no other call's. pub(crate) struct Instance { entry: PluginEntry, plugin: *mut c_void, - _ctx: LoadCtx, + _ctx: Box, } impl Instance { /// Takes ownership of the instance `plugin` that a successful - /// RV_PLUGIN_LOAD through `entry` set, and of the `ctx` it was handed. - pub(crate) fn new(entry: PluginEntry, plugin: *mut c_void, ctx: LoadCtx) -> Instance { + /// RV_PLUGIN_LOAD through `entry` set, and of the session its `ctx` + /// points at. + pub(crate) fn new(entry: PluginEntry, plugin: *mut c_void, ctx: Box) -> Instance { Instance { entry, plugin, _ctx: ctx, } } + + /// The entry point every later call goes through. + pub(crate) fn entry(&self) -> PluginEntry { + self.entry + } + + /// The pointer RV_PLUGIN_LOAD set, which every later call passes back + /// and the loader never reads through. + pub(crate) fn plugin(&self) -> *mut c_void { + self.plugin + } } impl Drop for Instance { @@ -165,12 +179,13 @@ impl Drop for Instance { } // SAFETY: the `# Threads and lifetime` section of `runvil-plugin-sys` lets -// RV_PLUGIN_UNLOAD come on any thread, the loader never reads `plugin`, and -// after its load returned the loader only frees the `ctx`, which any thread -// may do. +// every verb but RV_PLUGIN_LOAD come on any thread, the loader never reads +// through `plugin`, and after its load returned the loader only frees the +// `ctx`, which any thread may do. unsafe impl Send for Instance {} -// SAFETY: `Instance` has no method that takes `&self`, so sharing it -// between threads exposes nothing. +// SAFETY: the same section lets calls that name one Plugin instance run at +// the same time, and `&self` only hands out the entry point and the +// pointer those calls pass back. unsafe impl Sync for Instance {} /// What a loaded Plugin owns: its instance and, for a library Plugin, its @@ -178,7 +193,7 @@ unsafe impl Sync for Instance {} /// RV_PLUGIN_UNLOAD before the library closes, with no hand-written `Drop` /// for this struct. A linked Plugin has no library. pub(crate) struct Loaded { - _instance: Instance, + instance: Instance, _library: Option, } @@ -187,15 +202,21 @@ impl Loaded { /// any. pub(crate) fn new(instance: Instance, library: Option) -> Loaded { Loaded { - _instance: instance, + instance, _library: library, } } + + /// The live instance. + pub(crate) fn instance(&self) -> &Instance { + &self.instance + } } -/// Why one Plugin did not load: a `PluginLoadFailed` error with `message` -/// and the `Reason` fact `reason`, one of `open`, `missing_entry`, -/// `refused`, `misuse`, `status`, `undescribed`, or `duplicate`. +/// Why one Plugin did not load, or one of its Packs did not go into the +/// registry: a `PluginLoadFailed` error with `message` and the `Reason` +/// fact `reason`, one of `open`, `missing_entry`, `refused`, `misuse`, +/// `status`, `undescribed`, `duplicate`, or `pack`. pub(crate) fn make_load_error(reason: &str, message: String) -> Error { let code = ErrorCode::PluginLoadFailed; let mut error = Error::new(code, message, code.prose()); diff --git a/crates/harness-internal/plugin-loader/src/pack-tests.rs b/crates/harness-internal/plugin-loader/src/pack-tests.rs new file mode 100644 index 000000000..6866ccddb --- /dev/null +++ b/crates/harness-internal/plugin-loader/src/pack-tests.rs @@ -0,0 +1,472 @@ +//! Tests for the Packs a Plugin's entry point inserts: what reaches the +//! registry, what one run's instance holds, what `add_tool` and the Pack +//! `refuse` accept, and that RV_PACK_DELETE and RV_PLUGIN_UNLOAD each +//! come once, in that order. + +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use runvil_plugin::{ErrorCode, ErrorFact, PackOrigin, PackRegistry}; +use serde_json::json; + +use crate::fakes::*; +use crate::plugin::insert_plugin; + +/// The reason a run goes without a Pack whose Plugin misused the Pack +/// functions. +macro_rules! misuse_reason { + ($detail:expr) => { + concat!("the Plugin fake misused the Pack functions: ", $detail) + }; +} + +pack_fake!( + good, + { + /// Whether the RV_PACK_NEW call carried the contract's fields, and + /// the Pack name it was sent. + pub(super) static WELL_FORMED: AtomicUsize = AtomicUsize::new(0); + pub(super) static PACK_NAME: Mutex = Mutex::new(String::new()); + }, + new: |call| { + WELL_FORMED.store(usize::from(contract_holds(call, RV_PACK_NEW)), Ordering::SeqCst); + if let Ok(mut name) = PACK_NAME.lock() { + *name = text(pack_args(call).pack); + } + set_pack_run(call, instance(PACK_TAG)); + add_tool_info( + call, + &RvToolInfo { + wire_name: rv_str("Fetch"), + description: rv_str("Fetches a page."), + parameters: rv_str(r#"{"type":"object","properties":{"url":{"type":"string"}}}"#), + ..tool_info("fetch") + }, + ); + add_tool(call, "search") + }, + step: |_call| RV_ERR_UNSUPPORTED +); + +#[test] +fn a_plugins_pack_goes_into_the_registry_under_its_name_with_its_description() { + let mut packs = PackRegistry::new(); + let plugin = insert_plugin(&mut packs, good::entry).expect("the Plugin loads"); + assert_eq!(plugin.name(), "fake"); + let listed: Vec<(&str, &str, PackOrigin)> = packs + .list() + .filter(|(info, _)| info.fqn == FAKE_PACK) + .map(|(info, origin)| (info.fqn, info.description, origin)) + .collect(); + assert_eq!(listed, [(FAKE_PACK, "A fake Pack", PackOrigin::Plugin)]); +} + +#[test] +fn an_instance_holds_the_tools_the_plugin_added_under_the_pack_id() { + let pack = made(good::entry); + assert_eq!(good::WELL_FORMED.load(Ordering::SeqCst), 1); + assert_eq!(*good::PACK_NAME.lock().expect("the lock holds"), FAKE_PACK); + let tools = pack.tools(); + let listed: Vec<(String, Option<&str>, &str)> = tools + .iter() + .map(|tool| { + ( + tool.id.to_string(), + tool.wire_name.as_deref(), + tool.description.as_str(), + ) + }) + .collect(); + assert_eq!( + listed, + [ + ("fake/fetch".to_owned(), Some("Fetch"), "Fetches a page."), + ("fake/search".to_owned(), None, "A fake tool."), + ] + ); + assert_eq!( + tools[0].parameters_schema, + json!({ "type": "object", "properties": { "url": { "type": "string" } } }) + ); + assert!( + tools.iter().all(|tool| !tool.structured_output + && !tool.survives_stop + && !tool.describes_tools + && !tool.model_only_call), + "the contract carries no tool flags" + ); +} + +pack_fake!( + dropped, + {}, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + add_tool(call, "fetch") + }, + step: |_call| RV_ERR_UNSUPPORTED +); + +#[test] +fn dropping_an_instance_sends_one_delete_with_the_pointer_it_set_and_then_the_unload() { + let pack = made(dropped::entry); + assert_eq!(dropped::DELETES.load(Ordering::SeqCst), 0); + assert_eq!( + dropped::UNLOADS.load(Ordering::SeqCst), + 0, + "the instance keeps its Plugin loaded" + ); + drop(pack); + assert_eq!(dropped::DELETES.load(Ordering::SeqCst), 1); + assert_eq!(dropped::DELETED.load(Ordering::SeqCst), instance(PACK_TAG)); + assert_eq!( + *dropped::ORDER.lock().expect("the lock holds"), + [RV_PACK_DELETE, RV_PLUGIN_UNLOAD], + "the Plugin unloads only after its last Pack instance is deleted" + ); +} + +pack_fake!( + kept, + {}, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + add_tool(call, "fetch") + }, + step: |_call| RV_ERR_UNSUPPORTED +); + +#[test] +fn a_pack_in_the_registry_keeps_its_plugin_loaded_and_each_run_gets_its_own_instance() { + let mut packs = PackRegistry::new(); + drop(insert_plugin(&mut packs, kept::entry).expect("the Plugin loads")); + assert_eq!( + kept::UNLOADS.load(Ordering::SeqCst), + 0, + "the registry's Pack holds the Plugin" + ); + let (_info, id, factory) = packs + .into_entries() + .find(|(info, _, _)| info.fqn == FAKE_PACK) + .expect("the Plugin inserted its Pack"); + let factory = factory.expect("the Pack is usable"); + let first = factory.new_pack(&id).expect("the first run's instance"); + let second = factory.new_pack(&id).expect("the second run's instance"); + assert!(!Arc::ptr_eq(&first, &second)); + drop(factory); + drop(first); + assert_eq!(kept::DELETES.load(Ordering::SeqCst), 1); + assert_eq!( + kept::UNLOADS.load(Ordering::SeqCst), + 0, + "the second instance still holds the Plugin" + ); + drop(second); + assert_eq!(kept::DELETES.load(Ordering::SeqCst), 2); + assert_eq!(kept::UNLOADS.load(Ordering::SeqCst), 1); +} + +#[test] +fn a_pack_the_registry_refuses_fails_the_insert_and_names_the_pack() { + let mut packs = PackRegistry::new(); + let _first = insert_plugin(&mut packs, kept::entry).expect("the first Plugin loads"); + let failure = insert_plugin(&mut packs, good::entry).expect_err("the Pack name is taken"); + assert_eq!(failure.code, ErrorCode::PluginLoadFailed); + assert_eq!(reason(&failure), Some("pack")); + assert_eq!(failure.facts.get(&ErrorFact::Name), Some(&json!(FAKE_PACK))); + assert_eq!( + failure.message, + "the Pack acme/fake of the Plugin fake was not inserted: Pack `acme/fake` is already in \ + the registry" + ); +} + +#[test] +fn an_instance_with_no_tools_is_made() { + pack_fake!( + plug, + {}, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + RV_OK + }, + step: |_call| RV_ERR_UNSUPPORTED + ); + assert!(made(plug::entry).tools().is_empty()); +} + +#[test] +fn a_refusal_is_the_reason_the_run_goes_without_the_pack_and_gets_no_delete() { + pack_fake!( + plug, + {}, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + refuse_pack(call, "this run has no network") + }, + step: |_call| RV_ERR_UNSUPPORTED + ); + let failure = new_pack(plug::entry).err().expect("the Plugin refuses"); + assert_eq!(failure.message, "this run has no network"); + assert_eq!(failure.prose, "this run has no network"); + assert_eq!(plug::DELETES.load(Ordering::SeqCst), 0); + assert_eq!( + plug::UNLOADS.load(Ordering::SeqCst), + 1, + "nothing holds the Plugin once the factory and the failed instance are gone" + ); +} + +#[test] +fn a_status_other_than_ok_fails_the_instance_and_gets_no_delete() { + pack_fake!( + plug, + {}, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + add_tool(call, "fetch"); + ODD_STATUS + }, + step: |_call| RV_ERR_UNSUPPORTED + ); + let failure = new_pack(plug::entry).err().expect("the call fails"); + assert_eq!( + failure.message, + "the Plugin fake answered RV_PACK_NEW with status 77" + ); + assert_eq!(plug::DELETES.load(Ordering::SeqCst), 0); +} + +/// Defines a test whose fake RV_PACK_NEW sets an instance, runs `$misuse`, +/// a Pack function call that must answer RV_ERR_MISUSE, and then returns +/// RV_OK. The instance must fail with `$detail`, and a failed +/// RV_PACK_NEW is never deleted. +macro_rules! misuse_test { + ($test:ident, $detail:expr, |$call:ident| $misuse:expr) => { + #[test] + fn $test() { + pack_fake!( + plug, + { + pub(super) static SEEN: AtomicUsize = AtomicUsize::new(0); + }, + new: |$call| { + set_pack_run($call, instance(PACK_TAG)); + let status: RvStatus = $misuse; + SEEN.store(status as usize, Ordering::SeqCst); + RV_OK + }, + step: |_call| RV_ERR_UNSUPPORTED + ); + let failure = new_pack(plug::entry).err().expect("the instance fails"); + assert_eq!(failure.message, misuse_reason!($detail)); + assert_eq!(plug::SEEN.load(Ordering::SeqCst), RV_ERR_MISUSE as usize); + assert_eq!(plug::DELETES.load(Ordering::SeqCst), 0); + } + }; +} + +misuse_test!( + a_tool_name_with_two_segments_is_misuse, + "the tool's name is not one name segment, such as `fetch`", + |call| add_tool(call, "web/fetch") +); + +misuse_test!( + an_empty_tool_name_is_misuse, + "the tool's name is not one name segment, such as `fetch`", + |call| add_tool(call, "") +); + +misuse_test!( + a_tool_name_over_64_bytes_is_misuse, + "the tool's name is longer than 64 bytes", + |call| add_tool(call, &"a".repeat(65)) +); + +misuse_test!( + a_tool_added_twice_is_misuse, + "two tools of one Pack instance share a name", + |call| { + add_tool(call, "fetch"); + add_tool(call, "fetch") + } +); + +misuse_test!( + a_wire_name_with_a_slash_is_misuse, + "the tool's wire name is not ASCII letters, digits, `_`, and `-`", + |call| add_tool_info( + call, + &RvToolInfo { + wire_name: rv_str("web/fetch"), + ..tool_info("fetch") + } + ) +); + +misuse_test!( + a_wire_name_over_64_bytes_is_misuse, + "the tool's wire name is longer than 64 bytes", + |call| add_tool_info( + call, + &RvToolInfo { + wire_name: rv_str(&"A".repeat(65)), + ..tool_info("fetch") + } + ) +); + +misuse_test!( + an_empty_description_is_misuse, + "the tool's description is empty", + |call| add_tool_info( + call, + &RvToolInfo { + description: rv_str(""), + ..tool_info("fetch") + } + ) +); + +misuse_test!( + a_description_over_65536_bytes_is_misuse, + "the tool's description is longer than 65536 bytes", + |call| add_tool_info( + call, + &RvToolInfo { + description: rv_str(&"d".repeat(65537)), + ..tool_info("fetch") + } + ) +); + +misuse_test!( + parameters_that_are_not_json_are_misuse, + "the tool's parameter schema is not the JSON text of an object", + |call| add_tool_info( + call, + &RvToolInfo { + parameters: rv_str("{"), + ..tool_info("fetch") + } + ) +); + +misuse_test!( + parameters_that_are_not_an_object_are_misuse, + "the tool's parameter schema is not the JSON text of an object", + |call| add_tool_info( + call, + &RvToolInfo { + parameters: rv_str("[]"), + ..tool_info("fetch") + } + ) +); + +misuse_test!( + parameters_over_65536_bytes_are_misuse, + "the tool's parameter schema is longer than 65536 bytes", + |call| add_tool_info( + call, + &RvToolInfo { + parameters: rv_str(&" ".repeat(65537)), + ..tool_info("fetch") + } + ) +); + +misuse_test!( + a_short_tool_info_size_is_misuse, + "the info's size is shorter than RvToolInfo", + |call| add_tool_info( + call, + &RvToolInfo { + size: 71, + ..tool_info("fetch") + } + ) +); + +misuse_test!( + a_nonzero_reserved_field_in_a_tool_info_is_misuse, + "the info's reserved field is not zero", + |call| add_tool_info( + call, + &RvToolInfo { + reserved: 1, + ..tool_info("fetch") + } + ) +); + +misuse_test!( + a_null_tool_info_is_misuse, + "add_tool was handed a NULL info", + |call| { + // SAFETY: the loader's function accepts a NULL info and answers + // misuse; the `ctx` is the open call's. + unsafe { crate::pack::add_tool(pack_args(call).ctx, ptr::null()) } + } +); + +misuse_test!( + a_tool_added_through_another_ctx_is_misuse, + "a Pack function was handed a ctx other than the open RV_PACK_NEW call's", + |call| add_tool_as(call, ptr::null_mut(), &tool_info("fetch")) +); + +#[test] +fn a_second_refusal_of_an_instance_is_misuse_and_the_first_is_the_reason() { + pack_fake!( + plug, + { + pub(super) static SEEN: AtomicUsize = AtomicUsize::new(0); + }, + new: |call| { + refuse_pack(call, "first"); + let second = refuse_pack(call, "second"); + SEEN.store(second as usize, Ordering::SeqCst); + RV_ERR_REFUSED + }, + step: |_call| RV_ERR_UNSUPPORTED + ); + let failure = new_pack(plug::entry).err().expect("the Plugin refuses"); + assert_eq!(failure.message, "first"); + assert_eq!(plug::SEEN.load(Ordering::SeqCst), RV_ERR_MISUSE as usize); +} + +misuse_test!( + a_load_function_called_during_pack_new_is_misuse, + "a load function was called outside RV_PLUGIN_LOAD", + |call| describe_with(pack_args(call).ctx, "acme-url", "1.0.0") +); + +#[test] +fn tool_texts_at_their_bounds_are_accepted() { + pack_fake!( + plug, + {}, + new: |call| { + set_pack_run(call, instance(PACK_TAG)); + let padding = " ".repeat(65536 - NO_PARAMETERS.len()); + add_tool_info( + call, + &RvToolInfo { + wire_name: rv_str(&"W".repeat(64)), + description: rv_str(&"d".repeat(65536)), + parameters: rv_str(&format!("{NO_PARAMETERS}{padding}")), + ..tool_info(&"t".repeat(64)) + }, + ) + }, + step: |_call| RV_ERR_UNSUPPORTED + ); + let pack = made(plug::entry); + let tools = pack.tools(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].id.name(), "t".repeat(64)); + assert_eq!(tools[0].wire_name.as_deref(), Some("W".repeat(64).as_str())); + assert_eq!(tools[0].description.len(), 65536); +} diff --git a/crates/harness-internal/plugin-loader/src/pack.rs b/crates/harness-internal/plugin-loader/src/pack.rs new file mode 100644 index 000000000..4cbda2d23 --- /dev/null +++ b/crates/harness-internal/plugin-loader/src/pack.rs @@ -0,0 +1,258 @@ +//! The Packs a Plugin's entry point inserts: the factory the registry +//! holds for each one, one run's instance of it, the functions an +//! RV_PACK_NEW's `RvPackNewArgs` carries, and the senders of RV_PACK_NEW +//! and RV_PACK_DELETE. + +use std::ffi::c_void; +use std::mem; +use std::sync::Arc; + +use runvil_plugin::{ + BoxFuture, Error, ErrorCode, Pack, PackFactory, PackId, ToolContext, ToolId, ToolInfo, + ToolOutput, make_tool_error, +}; +use runvil_plugin_sys::{ + RV_MAX_NAME, RV_MAX_TOOL_TEXT, RV_OK, RV_PACK_DELETE, RV_PACK_NEW, RvCall, RvPackNewArgs, + RvStatus, RvStr, RvToolInfo, +}; +use serde_json::Value; + +use crate::call::Steps; +use crate::host::{is_segment, new_call}; +use crate::plugin::Plugin; +use crate::session::{ + self, AddedTool, Field, PACK_NEW, RESERVED, Shape, Verb, answer, field, rv_str, shape, + size_field, +}; + +#[cfg(test)] +#[path = "pack-tests.rs"] +mod tests; + +const TOOL_INFO: Shape = shape!("add_tool", "info", "RvToolInfo"); +const TOOL_NAME: Field = field!("the tool's name", 64, RV_MAX_NAME); +const WIRE_NAME: Field = field!("the tool's wire name", 64, RV_MAX_NAME); +const DESCRIPTION: Field = field!("the tool's description", 65536, RV_MAX_TOOL_TEXT); +const PARAMETERS: Field = field!("the tool's parameter schema", 65536, RV_MAX_TOOL_TEXT); + +/// Makes each run's instance of one Pack that a Plugin inserted through +/// its entry point. It holds an `Arc` of the Plugin, so the Plugin stays +/// loaded while the registry, or the `Packs` built from it, holds the +/// Pack. +pub(crate) struct EntryFactory { + plugin: Arc, + /// The Pack's name, as the Plugin gave it to `insert_pack`. + pack: String, +} + +impl EntryFactory { + /// The factory of the Pack `plugin` inserted under the name `pack`. + pub(crate) fn new(plugin: Arc, pack: String) -> EntryFactory { + EntryFactory { plugin, pack } + } +} + +impl PackFactory for EntryFactory { + /// Sends RV_PACK_NEW and makes the run's instance from the tools the + /// Plugin added. + /// + /// # Errors + /// Returns the Plugin's refusal with its reason as the message, or a + /// failure that names how the Plugin broke the contract or the status + /// RV_PACK_NEW returned. + fn new_pack(&self, id: &PackId) -> Result, Error> { + let (instance, added) = send_pack_new(&self.plugin, &self.pack)?; + // The instance owns `instance` from here, so a tool that cannot be + // named below still gets its RV_PACK_DELETE. + let mut pack = EntryPack { + plugin: Arc::clone(&self.plugin), + instance, + tools: Vec::new(), + }; + pack.tools = added + .into_iter() + .map(|tool| tool_info(id, tool)) + .collect::>()?; + Ok(Arc::new(pack)) + } +} + +/// The catalog entry of the tool `added` under the Pack id `id`. +fn tool_info(id: &PackId, added: AddedTool) -> Result { + let tool_id = ToolId::parse(&format!("{id}/{}", added.name)).map_err(|error| { + make_tool_error( + ErrorCode::ToolOther, + format!( + "the tool {} of the Pack {id} could not be named", + added.name + ), + ) + .with_cause(&error) + })?; + let info = ToolInfo::new(tool_id, added.description, added.parameters); + Ok(match added.wire_name { + Some(wire_name) => info.wire_name(wire_name), + None => info, + }) +} + +/// One run's instance of a Pack that a Plugin's entry point made. It +/// holds an `Arc` of the Plugin, so the Plugin outlives it, and dropping +/// it sends RV_PACK_DELETE once. +pub(crate) struct EntryPack { + plugin: Arc, + /// The pointer RV_PACK_NEW set, which each of the instance's calls + /// passes back and the loader never reads through. + instance: *mut c_void, + tools: Vec, +} + +impl EntryPack { + /// An `RvCall` that sends `verb` to this instance. + pub(crate) fn new_call(&self, verb: u32) -> RvCall { + RvCall { + pack_run: self.instance, + ..new_call(verb, self.plugin.instance().plugin()) + } + } + + /// Sends `call` through the Plugin's entry point. + /// + /// # Safety + /// `call` must follow the C contract for its verb, with this + /// instance's pointers, and its `args` must stay valid for the call. + pub(crate) unsafe fn send(&self, call: &mut RvCall) -> RvStatus { + // SAFETY: inserting the Plugin required its entry point to follow + // the contract and not unwind, and the caller vouches for `call`. + unsafe { (self.plugin.instance().entry())(call) } + } +} + +impl Drop for EntryPack { + fn drop(&mut self) { + let mut call = self.new_call(RV_PACK_DELETE); + // SAFETY: `instance` is the pointer a successful RV_PACK_NEW set, + // and this drop is its only delete. Each tool call of the instance + // borrows it, so every one of them has ended. + let _status = session::closed(|| unsafe { self.send(&mut call) }); + } +} + +// SAFETY: the `# Threads and lifetime` section of `runvil-plugin-sys` lets +// RV_PACK_DELETE and each tool call come on any thread, and the loader +// never reads through `instance`. +unsafe impl Send for EntryPack {} +// SAFETY: the same section lets tool calls of one Pack instance run at the +// same time, and `&self` only hands out the pointer those calls pass back. +unsafe impl Sync for EntryPack {} + +impl Pack for EntryPack { + fn tools(&self) -> Vec { + self.tools.clone() + } + + fn call<'a>( + &'a self, + cx: ToolContext<'a>, + args: Value, + ) -> BoxFuture<'a, Result> { + Box::pin(Steps::new(self, cx.tool().name(), &args, cx.access())) + } +} + +/// Records the Plugin's refusal to make a Pack instance, validated; +/// returns RV_ERR_REFUSED, or RV_ERR_MISUSE for a `ctx` it doesn't answer +/// or a second refusal. +pub(crate) unsafe extern "C" fn refuse(ctx: *mut c_void, reason: RvStr) -> RvStatus { + session::refuse(ctx, &PACK_NEW, reason) +} + +/// Records one tool the instance adds, validated; returns RV_OK or +/// RV_ERR_MISUSE. +pub(crate) unsafe extern "C" fn add_tool(ctx: *mut c_void, info: *const RvToolInfo) -> RvStatus { + answer(ctx, &PACK_NEW, |session| { + // SAFETY: the contract makes `info` point at a readable + // `RvToolInfo` during this call. + let info = unsafe { TOOL_INFO.read(info) }?; + if info.reserved != 0 { + return Err(RESERVED); + } + // SAFETY: the contract makes each string valid for its length. + let name = unsafe { TOOL_NAME.read(info.name) }?; + if !is_segment(&name) { + return Err("the tool's name is not one name segment, such as `fetch`"); + } + if session.tools.iter().any(|tool| tool.name == name) { + return Err("two tools of one Pack instance share a name"); + } + // SAFETY: as above. + let wire_name = unsafe { WIRE_NAME.read(info.wire_name) }?; + if !wire_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') + { + return Err("the tool's wire name is not ASCII letters, digits, `_`, and `-`"); + } + // SAFETY: as above. + let description = unsafe { DESCRIPTION.read(info.description) }?; + if description.is_empty() { + return Err("the tool's description is empty"); + } + // SAFETY: as above. + let parameters = unsafe { PARAMETERS.read(info.parameters) }?; + let parameters = serde_json::from_str::(¶meters) + .ok() + .filter(Value::is_object) + .ok_or("the tool's parameter schema is not the JSON text of an object")?; + session.tools.push(AddedTool { + name, + wire_name: (!wire_name.is_empty()).then_some(wire_name), + description, + parameters, + }); + Ok(RV_OK) + }) +} + +/// Sends RV_PACK_NEW for the Pack `plugin` inserted under the name `pack`, +/// and returns the instance pointer the Plugin set and the tools it +/// added. A call this rejects gets no RV_PACK_DELETE. +/// +/// The message of each failure is the reason the run goes without the +/// Pack: the Plugin's own reason for a refusal, how the Plugin broke the +/// contract, or the status it returned. +fn send_pack_new(plugin: &Plugin, pack: &str) -> Result<(*mut c_void, Vec), Error> { + let mut call = new_call(RV_PACK_NEW, plugin.instance().plugin()); + let (status, mut session) = session::open(Verb::PackNew, |ctx| { + let args = RvPackNewArgs { + size: size_field::(), + reserved: 0, + ctx, + pack: rv_str(pack), + refuse, + add_tool, + }; + call.args = (&raw const args).cast(); + // SAFETY: inserting the Plugin required its entry point to follow + // the contract and not unwind, `call` is a live `RvCall` that + // nothing else uses, and its `args` is live for the call. + unsafe { (plugin.instance().entry())(&raw mut call) } + }); + let unavailable = |message: String| make_tool_error(ErrorCode::ToolOther, message); + if let Some(reason) = session.refusal.take() { + return Err(unavailable(reason)); + } + if let Some(misuse) = session.misuse.take() { + return Err(unavailable(format!( + "the Plugin {} misused the Pack functions: {misuse}", + plugin.name() + ))); + } + if status != RV_OK { + return Err(unavailable(format!( + "the Plugin {} answered RV_PACK_NEW with status {status}", + plugin.name() + ))); + } + Ok((call.pack_run, mem::take(&mut session.tools))) +} diff --git a/crates/harness-internal/plugin-loader/src/plugin.rs b/crates/harness-internal/plugin-loader/src/plugin.rs index f88966d15..6a4a3d4a4 100644 --- a/crates/harness-internal/plugin-loader/src/plugin.rs +++ b/crates/harness-internal/plugin-loader/src/plugin.rs @@ -7,25 +7,32 @@ use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; -use runvil_plugin::{Error, ErrorFact, PackRegistry}; +use runvil_plugin::{Error, ErrorFact, PackInfo, PackRegistry}; use runvil_plugin_sys::PluginEntry; -use crate::host::send_load; +use crate::host::{Loading, send_load}; use crate::load::{Instance, Loaded, make_load_error, open_entry, plugin_files}; +use crate::pack::EntryFactory; +use crate::session::InsertedPack; #[cfg(test)] #[path = "plugin-tests.rs"] mod tests; -/// Opens every dynamic library in `directory`, in file-name order, and -/// loads each one's Plugin by sending it RV_PLUGIN_LOAD. Its Packs will go -/// into `packs`; in this MVP no Plugin inserts any. A file that can't be -/// opened, has no `runvil_plugin`, fails to load, or repeats a Plugin name -/// this call already loaded is recorded in the report as a failure, and one -/// Plugin's failure never stops the others. A repeat did load, so it is -/// unloaded and dropped. A dependency library beside a Plugin is opened -/// too, and reported as missing `runvil_plugin`. Names are unique within -/// one call, and nothing remembers them across calls. +/// Opens every dynamic library in `directory`, in file-name order, loads +/// each one's Plugin by sending it RV_PLUGIN_LOAD, and inserts the Packs +/// that Plugin offers into `packs`. A file that can't be opened, has no +/// `runvil_plugin`, fails to load, or repeats a Plugin name this call +/// already loaded is recorded in the report as a failure, and one Plugin's +/// failure never stops the others. A repeat did load, so it is unloaded +/// and dropped, and none of its Packs go in. A Pack that `packs` refuses, +/// such as one whose name it already holds, is recorded as a failure too, +/// and the Plugin's other Packs still go in. A dependency library beside a +/// Plugin is opened too, and reported as missing `runvil_plugin`. Names +/// are unique within one call, and nothing remembers them across calls. +/// +/// Each Pack that goes in keeps its Plugin loaded for as long as `packs`, +/// or what a Host builds from it, holds the Pack. /// /// This safe function runs foreign code: opening a library runs its /// initialization code, and each `runvil_plugin` it calls has no way to be @@ -37,10 +44,6 @@ mod tests; /// Returns the I/O error when `directory`, or the metadata of an entry in /// it with the platform's library extension, cannot be read. Other entries /// are never examined beyond their names. -#[expect( - unused_variables, - reason = "Plugins insert no Packs until insert_pack exists" -)] pub fn insert_plugin_dir(packs: &mut PackRegistry, directory: &Path) -> io::Result { let mut report = PluginReport { plugins: Vec::new(), @@ -48,7 +51,7 @@ pub fn insert_plugin_dir(packs: &mut PackRegistry, directory: &Path) -> io::Resu }; for path in plugin_files(directory)? { match load_library(&path) { - Ok(plugin) + Ok((plugin, _)) if report .plugins .iter() @@ -69,7 +72,14 @@ pub fn insert_plugin_dir(packs: &mut PackRegistry, directory: &Path) -> io::Resu // closes its library, like any other Plugin's. drop(plugin); } - Ok(plugin) => report.plugins.push(Arc::new(plugin)), + Ok((plugin, inserted)) => { + let plugin = Arc::new(plugin); + let refused = insert_packs(packs, &plugin, inserted); + report + .failures + .extend(refused.into_iter().map(|error| with_path(error, &path))); + report.plugins.push(plugin); + } Err(error) => report.failures.push(with_path(error, &path)), } } @@ -86,7 +96,7 @@ fn with_path(mut error: Error, path: &Path) -> Error { /// Opens the library at `path` and loads its Plugin. A failing load closes /// the library before this returns. -fn load_library(path: &Path) -> Result { +fn load_library(path: &Path) -> Result<(Plugin, Vec), Error> { let (library, entry) = open_entry(path)?; // SAFETY: `insert_plugin_dir`'s documentation requires every // `runvil_plugin` to follow the C contract and not unwind, and @@ -95,8 +105,9 @@ fn load_library(path: &Path) -> Result { } /// Sends `entry` RV_PLUGIN_LOAD and, when that succeeds, makes the Plugin -/// that owns its instance and `library`. When it fails, `library` drops -/// at once, and no RV_PLUGIN_UNLOAD is sent. +/// that owns its instance and `library`, beside the Packs it inserted. +/// When it fails, `library` drops at once, and no RV_PLUGIN_UNLOAD is +/// sent. /// /// # Safety /// `entry` must follow the C contract in `runvil-plugin-sys` and must not @@ -106,22 +117,64 @@ unsafe fn load( source: PluginSource, entry: PluginEntry, library: Option, -) -> Result { +) -> Result<(Plugin, Vec), Error> { // SAFETY: the caller guarantees `entry` follows the C contract and does // not unwind. - let (described, instance, ctx) = unsafe { send_load(entry) }?; - Ok(Plugin { + let Loading { + described, + packs, + instance, + ctx, + } = unsafe { send_load(entry) }?; + let plugin = Plugin { name: described.name, version: described.version, source, - _loaded: Loaded::new(Instance::new(entry, instance, ctx), library), - }) + loaded: Loaded::new(Instance::new(entry, instance, ctx), library), + }; + Ok((plugin, packs)) +} + +/// Inserts each Pack `plugin` offered into `packs`, each with a factory +/// that keeps the Plugin loaded, and returns the failure of each one +/// `packs` refused: a `PluginLoadFailed` error with the `Reason` fact +/// `pack`, the Pack's name as its `Name` fact, and the refusal as its +/// cause. +/// +/// A `PackInfo` borrows its text for `'static`, and a Plugin's text lives +/// only as long as the Plugin, so each Pack's name and description are +/// leaked: a few bytes for each Pack, once for each insert. +fn insert_packs( + packs: &mut PackRegistry, + plugin: &Arc, + inserted: Vec, +) -> Vec { + let mut refused = Vec::new(); + for InsertedPack { name, description } in inserted { + let info = PackInfo::new(String::leak(name.clone())).description(String::leak(description)); + let factory = EntryFactory::new(Arc::clone(plugin), name.clone()); + if let Err(refusal) = packs.insert(info, factory) { + let mut error = make_load_error( + "pack", + format!( + "the Pack {name} of the Plugin {} was not inserted: {refusal}", + plugin.name + ), + ) + .with_cause(&refusal); + error.facts.insert(ErrorFact::Name, name.into()); + refused.push(error); + } + } + refused } /// Loads a Plugin linked into the Host by sending `entry` RV_PLUGIN_LOAD, -/// and returns it. Its Packs will go into `packs`; in this MVP no Plugin -/// inserts any. `entry` must follow the C contract in -/// `runvil-plugin-sys`, as a library's must. +/// inserts the Packs it offers into `packs`, and returns it. `entry` must +/// follow the C contract in `runvil-plugin-sys`, as a library's must. +/// +/// Each Pack that goes in keeps the Plugin loaded for as long as `packs`, +/// or what a Host builds from it, holds the Pack. /// /// This safe function runs foreign code: it calls `entry` with no way to /// check it, so a Host passes only an entry point it trusts, and `entry` @@ -130,15 +183,17 @@ unsafe fn load( /// # Errors /// Returns the `PluginLoadFailed` failure, with no `Path` fact, when the /// Plugin refuses, misuses the load functions, or does not describe -/// itself. -#[expect( - unused_variables, - reason = "Plugins insert no Packs until insert_pack exists" -)] +/// itself, and the failure of the first Pack that `packs` refuses. The +/// Packs that went in before that one stay in `packs`. pub fn insert_plugin(packs: &mut PackRegistry, entry: PluginEntry) -> Result, Error> { // SAFETY: this function's documentation requires `entry` to follow the // C contract and not unwind, and a linked Plugin has no library. - unsafe { load(PluginSource::Linked, entry, None) }.map(Arc::new) + let (plugin, inserted) = unsafe { load(PluginSource::Linked, entry, None) }?; + let plugin = Arc::new(plugin); + match insert_packs(packs, &plugin, inserted).into_iter().next() { + Some(refused) => Err(refused), + None => Ok(plugin), + } } /// What one `insert_plugin_dir` call loaded and what failed. It holds one @@ -158,8 +213,8 @@ impl PluginReport { /// Every failure, in the order it happened: a `PluginLoadFailed` error /// whose message says why, with the library's path as its `Path` fact, - /// the failure's kind as its `Reason` fact, and, for a repeat, the - /// repeated Plugin name as its `Name` fact. + /// the failure's kind as its `Reason` fact, and, for a repeated Plugin + /// name or a Pack the registry refused, that name as its `Name` fact. #[must_use] pub fn failures(&self) -> &[Error] { &self.failures @@ -178,17 +233,18 @@ pub enum PluginSource { } /// One Plugin that loaded and named itself, shared through `Arc`. It owns -/// its instance and, for a library Plugin, its library. When the last `Arc` -/// drops, it sends RV_PLUGIN_UNLOAD, then closes the library. +/// its instance and, for a library Plugin, its library. Each Pack it +/// inserted and each Pack instance made from one holds an `Arc` of it. +/// When the last `Arc` drops, it sends RV_PLUGIN_UNLOAD, then closes the +/// library. pub struct Plugin { - /// At most 64 bytes, one segment, by the same rules as a Pack id. + /// At most 64 bytes, one name segment. name: String, /// Non-empty, at most 64 bytes, no control characters. version: String, source: PluginSource, - /// Held only so its drop sends RV_PLUGIN_UNLOAD and then closes the - /// library. - _loaded: Loaded, + /// Its drop sends RV_PLUGIN_UNLOAD and then closes the library. + loaded: Loaded, } impl Plugin { @@ -209,6 +265,11 @@ impl Plugin { pub fn source(&self) -> &PluginSource { &self.source } + + /// The live instance every later call names. + pub(crate) fn instance(&self) -> &Instance { + self.loaded.instance() + } } /// Shows the name, version, and source, never the library or the instance. diff --git a/crates/harness-internal/plugin-loader/src/session-tests.rs b/crates/harness-internal/plugin-loader/src/session-tests.rs new file mode 100644 index 000000000..38e241ce5 --- /dev/null +++ b/crates/harness-internal/plugin-loader/src/session-tests.rs @@ -0,0 +1,200 @@ +//! Tests for the checks every function a Plugin calls back begins with: +//! what a string field and a struct pointer are refused for, and which +//! call a function answers. + +use std::ffi::c_void; +use std::ptr; + +use runvil_plugin_sys::{RV_ERR_MISUSE, RV_ERR_REFUSED, RV_OK, RvPluginInfo, RvStat, RvStr}; + +use super::{ + Field, LOAD, Out, PACK_NEW, Shape, TOOL_CALL, Verb, answer, answer_file, closed, field, open, + open_step, out, refuse, rv_str, shape, +}; +use crate::fakes::test_call; + +const WORD: Field = field!("the word", 4); +const INFO: Shape = shape!("describe_plugin", "info", "RvPluginInfo"); +const STAT: Out = out!("file_stat", "stat"); + +fn bytes(bytes: &[u8]) -> RvStr { + RvStr { + ptr: bytes.as_ptr().cast(), + len: bytes.len(), + } +} + +#[test] +fn a_field_copies_a_string_within_its_bound() { + // SAFETY: the string points at a live literal of its length. + let word = unsafe { WORD.read(rv_str("word")) }; + assert_eq!(word, Ok("word".to_owned())); + let null_and_empty = RvStr { + ptr: ptr::null(), + len: 0, + }; + // SAFETY: a zero length reads no byte. + let empty = unsafe { WORD.read(null_and_empty) }; + assert_eq!(empty, Ok(String::new())); +} + +#[test] +fn a_field_names_each_way_a_string_breaks_its_rules() { + // SAFETY: the length is refused before any byte is read. + let too_long = unsafe { WORD.read(rv_str("words")) }; + assert_eq!(too_long, Err("the word is longer than 4 bytes")); + let null = RvStr { + ptr: ptr::null(), + len: 2, + }; + // SAFETY: a NULL pointer is refused before any byte is read. + let null = unsafe { WORD.read(null) }; + assert_eq!( + null, + Err("the word has a NULL pointer and a nonzero length") + ); + // SAFETY: the string points at a live literal of its length. + let not_utf8 = unsafe { WORD.read(bytes(&[0xff, 0xfe])) }; + assert_eq!(not_utf8, Err("the word is not UTF-8")); +} + +#[test] +fn a_shape_names_each_way_a_struct_is_handed_over_wrongly() { + let whole = RvPluginInfo { + size: 40, + reserved: 0, + name: rv_str("a"), + version: rv_str("1"), + }; + // SAFETY: `whole` is a live `RvPluginInfo` of its stated size. + assert!(unsafe { INFO.read(&raw const whole) }.is_ok()); + // SAFETY: NULL is refused before anything is read. + let null = unsafe { INFO.read(ptr::null::()) }; + assert_eq!(null.err(), Some("describe_plugin was handed a NULL info")); + let odd = (&raw const whole).wrapping_byte_add(1); + // SAFETY: a misaligned pointer is refused before anything is read. + let misaligned = unsafe { INFO.read(odd) }; + assert_eq!( + misaligned.err(), + Some("describe_plugin was handed a misaligned info") + ); + let short = RvPluginInfo { size: 39, ..whole }; + // SAFETY: `short` is whole; only its `size` field claims less. + let short = unsafe { INFO.read(&raw const short) }; + assert_eq!( + short.err(), + Some("the info's size is shorter than RvPluginInfo") + ); +} + +#[test] +fn an_out_names_each_way_a_struct_to_fill_is_handed_over_wrongly() { + let mut stat = RvStat { + file_type: 0, + reserved: 0, + len: 0, + }; + let at = &raw mut stat; + assert_eq!(STAT.check(at), Ok(at)); + assert_eq!( + STAT.check(ptr::null_mut::()), + Err("file_stat was handed a NULL stat pointer") + ); + assert_eq!( + STAT.check(at.wrapping_byte_add(1)), + Err("file_stat was handed a misaligned stat pointer") + ); +} + +#[test] +fn a_file_function_gets_the_access_of_the_open_step_and_no_other_call_has_one() { + let call = test_call("tool"); + let access = call.context().access(); + let (status, session) = open_step(access, |ctx| { + answer_file(ctx, |files, _lent| { + assert!(ptr::eq(files, access), "the step's own access"); + Ok(RV_OK) + }) + }); + assert_eq!(status, RV_OK); + assert_eq!(session.misuse, None); + + for verb in [Verb::Load, Verb::PackNew, Verb::ToolCall] { + let (status, session) = open(verb, |ctx| answer_file(ctx, |_, _| Ok(RV_OK))); + assert_eq!(status, RV_ERR_MISUSE, "{verb:?}"); + assert_eq!(session.misuse, Some(TOOL_CALL.wrong_call), "{verb:?}"); + } +} + +#[test] +fn a_function_answers_only_the_open_call_of_its_own_verb_through_its_own_ctx() { + for (verb, family, other) in [ + (Verb::Load, &LOAD, &PACK_NEW), + (Verb::PackNew, &PACK_NEW, &TOOL_CALL), + (Verb::ToolCall, &TOOL_CALL, &LOAD), + ] { + let (status, session) = open(verb, |ctx| answer(ctx, family, |_| Ok(RV_OK))); + assert_eq!(status, RV_OK, "{verb:?}"); + assert_eq!(session.misuse, None, "{verb:?}"); + + let (status, session) = open(verb, |ctx| answer(ctx, other, |_| Ok(RV_OK))); + assert_eq!(status, RV_ERR_MISUSE, "{verb:?}"); + assert_eq!(session.misuse, Some(other.wrong_call), "{verb:?}"); + + let (status, session) = open(verb, |_| answer(ptr::null_mut(), family, |_| Ok(RV_OK))); + assert_eq!(status, RV_ERR_MISUSE, "{verb:?}"); + assert_eq!(session.misuse, Some(family.wrong_ctx), "{verb:?}"); + } +} + +#[test] +fn a_function_called_with_no_call_open_answers_misuse_and_reads_nothing() { + let mut stale = 0_u64; + let stale: *mut c_void = (&raw mut stale).cast(); + assert_eq!(answer(stale, &LOAD, |_| Ok(RV_OK)), RV_ERR_MISUSE); + let (status, session) = open(Verb::Load, |ctx| { + closed(|| answer(ctx, &LOAD, |_| Ok(RV_OK))) + }); + assert_eq!( + status, RV_ERR_MISUSE, + "a call with no arguments runs with no session open" + ); + assert_eq!(session.misuse, None, "and it records nothing"); +} + +#[test] +fn only_the_first_misuse_of_a_call_is_kept() { + let (_, session) = open(Verb::Load, |ctx| { + answer(ctx, &LOAD, |_| Err("first")); + answer(ctx, &LOAD, |_| Err("second")) + }); + assert_eq!(session.misuse, Some("first")); +} + +#[test] +fn a_call_inside_a_call_keeps_the_outer_session_open() { + let (status, outer) = open(Verb::Load, |outer_ctx| { + let (inner_status, inner) = open(Verb::PackNew, |inner_ctx| { + // The outer call's `ctx` is not the open call's meanwhile. + answer(outer_ctx, &LOAD, |_| Ok(RV_OK)); + answer(inner_ctx, &PACK_NEW, |_| Ok(RV_OK)) + }); + assert_eq!(inner_status, RV_OK); + assert_eq!(inner.misuse, Some(LOAD.wrong_ctx)); + answer(outer_ctx, &LOAD, |_| Ok(RV_OK)) + }); + assert_eq!(status, RV_OK, "the outer call is open again"); + assert_eq!(outer.misuse, None); +} + +#[test] +fn a_refusal_is_recorded_once_with_its_reason() { + let (status, session) = open(Verb::PackNew, |ctx| { + let first = refuse(ctx, &PACK_NEW, rv_str("no network")); + assert_eq!(first, RV_ERR_REFUSED); + refuse(ctx, &PACK_NEW, rv_str("again")) + }); + assert_eq!(status, RV_ERR_MISUSE); + assert_eq!(session.refusal.as_deref(), Some("no network")); + assert_eq!(session.misuse, Some("refuse was called twice")); +} diff --git a/crates/harness-internal/plugin-loader/src/session.rs b/crates/harness-internal/plugin-loader/src/session.rs new file mode 100644 index 000000000..ccc5eaaa3 --- /dev/null +++ b/crates/harness-internal/plugin-loader/src/session.rs @@ -0,0 +1,463 @@ +//! The call the loader has open on a thread: the [`Session`] its `ctx` +//! points at, the thread-local slot that names it, and the checks every +//! function a Plugin calls back begins with. + +use std::cell::Cell; +use std::ffi::c_void; +use std::mem::size_of; +use std::panic::{RefUnwindSafe, UnwindSafe}; +use std::ptr::{self, NonNull}; +use std::slice; + +use runvil_plugin::{Access, Error, ToolOutput}; +use runvil_plugin_sys::{RV_ERR_MISUSE, RV_ERR_REFUSED, RV_MAX_LINE, RvStatus, RvStr}; +use serde_json::Value; + +use crate::file::Lent; + +#[cfg(test)] +#[path = "session-tests.rs"] +mod tests; + +thread_local! { + /// The session of the call open on this thread, which its `ctx` points + /// at, or `None` while no call is open. A `Cell`, so no borrow is ever + /// held and no borrow check can fail: `answer` takes it out while it + /// works, and `open` and `closed` swap it. + static OPEN: Cell>> = const { Cell::new(None) }; +} + +/// The verb of an open call, which decides the functions that answer it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Verb { + /// `RV_PLUGIN_LOAD`. + Load, + /// `RV_PACK_NEW`. + PackNew, + /// `RV_TOOL_CALL`. + ToolCall, +} + +/// What one call into a Plugin has reported so far; the call's `ctx` +/// points at it. +pub(crate) struct Session { + /// The call's verb. + verb: Verb, + /// The first way the Plugin broke the contract during the call. + pub(crate) misuse: Option<&'static str>, + /// The reason the Plugin declined with. + pub(crate) refusal: Option, + /// The name and version a load described the Plugin with. + pub(crate) described: Option, + /// The Packs a load inserted, in order. + pub(crate) packs: Vec, + /// The tools an `RV_PACK_NEW` added, in order. + pub(crate) tools: Vec, + /// The result a step finished its call with. + pub(crate) result: Option>, + /// The files a step's file functions read: the access of the step's + /// tool call. `open_step` sets it while the step runs, and it is + /// `None` in every other call and once the step has returned. + access: Option, + /// What a step's last file function handed the Plugin, which stays + /// valid until the next one, or until the step returns. + lent: Lent, +} + +/// The access of the tool call whose step is open. A pointer, not a +/// borrow, because a session is a `'static` allocation that its `ctx` +/// names. +#[derive(Clone, Copy)] +struct StepAccess(NonNull); + +// A loaded Plugin owns the session of its load, which never names an +// access, and a step's session names one only while the step runs. So the +// pointer gives nobody a view of an `Access` after a panic, and a `Plugin` +// stays unwind-safe, as it is without this field. +impl UnwindSafe for StepAccess {} +impl RefUnwindSafe for StepAccess {} + +/// The name and version a Plugin gave `describe_plugin`, copied. +pub(crate) struct Described { + pub(crate) name: String, + pub(crate) version: String, +} + +/// A Pack a Plugin gave `insert_pack`, copied. +pub(crate) struct InsertedPack { + /// A vendor and a Pack id joined by `/`. + pub(crate) name: String, + pub(crate) description: String, +} + +/// A tool a Pack instance gave `add_tool`, copied. +pub(crate) struct AddedTool { + /// One name segment. + pub(crate) name: String, + /// `None` for the wire name the Engine derives. + pub(crate) wire_name: Option, + pub(crate) description: String, + /// The parsed JSON Schema, an object. + pub(crate) parameters: Value, +} + +/// Sends one call of `verb` with a fresh session open on this thread. +/// `send` gets the session's `ctx` and returns the Plugin's status. The +/// session is closed before this returns that status and what the Plugin +/// reported, so no slot holds the session and nothing else reaches it. +pub(crate) fn open( + verb: Verb, + send: impl FnOnce(*mut c_void) -> RvStatus, +) -> (RvStatus, Box) { + open_over(verb, None, send) +} + +/// Sends one step of a tool call, as [`open`] sends a call, with `access` +/// as the files its file functions read. +pub(crate) fn open_step( + access: &Access, + send: impl FnOnce(*mut c_void) -> RvStatus, +) -> (RvStatus, Box) { + open_over(Verb::ToolCall, Some(access), send) +} + +/// Sends one call of `verb` over `access`; see [`open`]. +fn open_over( + verb: Verb, + access: Option<&Access>, + send: impl FnOnce(*mut c_void) -> RvStatus, +) -> (RvStatus, Box) { + let session = NonNull::from(Box::leak(Box::new(Session { + verb, + misuse: None, + refusal: None, + described: None, + packs: Vec::new(), + tools: Vec::new(), + result: None, + access: access.map(|access| StepAccess(NonNull::from(access))), + lent: Lent::default(), + }))); + // A call inside a call, on one thread, keeps the outer session. + let outer = OPEN.with(|cell| cell.replace(Some(session))); + let status = send(session.as_ptr().cast()); + // Everything that touches the slot while `send` runs (`answer`, `open`, + // and `closed`) puts back what it replaced, so this swap closes the + // session opened above. + OPEN.with(|cell| cell.set(outer)); + // SAFETY: `session` came from `Box::leak` above, and with the call + // closed no slot holds it, so this is its only owner. + let mut session = unsafe { Box::from_raw(session.as_ptr()) }; + // The borrow of `access` ends with this function, so the session must + // not name it past here, and what was lent is no longer valid. + session.access = None; + session.lent = Lent::default(); + (status, session) +} + +/// Sends a call that has no arguments, with no session open on this +/// thread, so a function the Plugin calls meanwhile answers +/// RV_ERR_MISUSE. +pub(crate) fn closed(send: impl FnOnce() -> RvStatus) -> RvStatus { + // A call inside a call, on one thread, must not see the outer session. + // The last `Arc` of a Plugin may drop inside a thread-local destructor; + // `try_with` keeps such a call going out even on a thread whose slot is + // gone, which `answer` treats as no open call. + let outer = OPEN.try_with(Cell::take).unwrap_or_default(); + let status = send(); + let _restored = OPEN.try_with(|cell| cell.set(outer)); + status +} + +/// The functions of one verb's arguments struct, and what a breach of the +/// `ctx` rules by one of them is called. +pub(crate) struct Family { + verb: Verb, + wrong_ctx: &'static str, + wrong_call: &'static str, +} + +/// The functions in an `RvLoadArgs`. +pub(crate) const LOAD: Family = Family { + verb: Verb::Load, + wrong_ctx: "a load function was handed a ctx other than the open RV_PLUGIN_LOAD call's", + wrong_call: "a load function was called outside RV_PLUGIN_LOAD", +}; + +/// The functions in an `RvPackNewArgs`. +pub(crate) const PACK_NEW: Family = Family { + verb: Verb::PackNew, + wrong_ctx: "a Pack function was handed a ctx other than the open RV_PACK_NEW call's", + wrong_call: "a Pack function was called outside RV_PACK_NEW", +}; + +/// The functions in an `RvToolCallArgs`. +pub(crate) const TOOL_CALL: Family = Family { + verb: Verb::ToolCall, + wrong_ctx: "a tool call function was handed a ctx other than the open RV_TOOL_CALL step's", + wrong_call: "a tool call function was called outside RV_TOOL_CALL", +}; + +/// Answers a function of `family`. `f` runs on the session only when this +/// thread has a call of the family's verb open and `ctx` is its `ctx`; +/// otherwise the answer is RV_ERR_MISUSE. A fault `f` returns is answered +/// the same way, and an open call records the first misuse. Nothing here +/// reads through `ctx` itself, so a stale, freed, or made-up `ctx` is only +/// compared. +/// +/// The session is out of its slot while `f` runs, so a function that `f` +/// somehow reached would find no open call and answer RV_ERR_MISUSE. +pub(crate) fn answer( + ctx: *mut c_void, + family: &Family, + f: impl FnOnce(&mut Session) -> Result, +) -> RvStatus { + OPEN.try_with(|cell| { + let Some(open) = cell.take() else { + return RV_ERR_MISUSE; + }; + // SAFETY: only `open` fills the slot, with the live session of the + // call it has open on this thread, and it empties the slot before + // that session can be freed. The slot is empty while this runs, so + // nothing else reaches the session meanwhile. + let session = unsafe { &mut *open.as_ptr() }; + let outcome = if !ptr::eq(open.as_ptr().cast::(), ctx) { + Err(family.wrong_ctx) + } else if session.verb != family.verb { + Err(family.wrong_call) + } else { + f(session) + }; + let status = outcome.unwrap_or_else(|fault| { + session.misuse.get_or_insert(fault); + RV_ERR_MISUSE + }); + cell.set(Some(open)); + status + }) + .unwrap_or(RV_ERR_MISUSE) +} + +/// Answers a file function, as [`answer`] answers a function of +/// [`TOOL_CALL`]. `f` gets the files of the open step and the place that +/// keeps what the function hands back. +pub(crate) fn answer_file( + ctx: *mut c_void, + f: impl FnOnce(&Access, &mut Lent) -> Result, +) -> RvStatus { + answer(ctx, &TOOL_CALL, |session| { + // Only `open_step` opens a step, and it always names an access. + let StepAccess(access) = session.access.ok_or(TOOL_CALL.wrong_call)?; + // SAFETY: `open_step` set the pointer from a reference that + // outlives the step and clears it when the step returns, and + // `answer` runs this only while that step is open on this thread. + f(unsafe { access.as_ref() }, &mut session.lent) + }) +} + +/// Records the Plugin's refusal of the open call of `family`, validated; +/// returns RV_ERR_REFUSED, or RV_ERR_MISUSE for a `ctx` it doesn't answer +/// or a second refusal. +pub(crate) fn refuse(ctx: *mut c_void, family: &Family, reason: RvStr) -> RvStatus { + answer(ctx, family, |session| { + if session.refusal.is_some() { + return Err("refuse was called twice"); + } + // SAFETY: the contract makes `reason` valid for its length during + // this call. + session.refusal = Some(unsafe { REASON.read(reason) }?); + Ok(RV_ERR_REFUSED) + }) +} + +/// One string's bound, and what each way of breaking it is called. +pub(crate) struct Field { + cap: usize, + too_long: &'static str, + null_with_length: &'static str, + not_utf8: &'static str, +} + +/// The [`Field`] of the string `$what` names, bounded at `$cap` bytes. +/// The number is a literal, because it is part of the text of a misuse. A +/// third argument is the contract's limit, which the number must equal. +macro_rules! field { + ($what:literal, $cap:literal $(, $limit:expr)?) => {{ + $(const _: () = assert!($cap == $limit);)? + $crate::session::Field::new( + $cap, + concat!($what, " is longer than ", $cap, " bytes"), + concat!($what, " has a NULL pointer and a nonzero length"), + concat!($what, " is not UTF-8"), + ) + }}; +} +pub(crate) use field; + +/// A refusal's reason. +const REASON: Field = field!("the refusal reason", 1024, RV_MAX_LINE); + +impl Field { + /// A field bounded at `cap` bytes, with the name of each breach. + pub(crate) const fn new( + cap: usize, + too_long: &'static str, + null_with_length: &'static str, + not_utf8: &'static str, + ) -> Field { + Field { + cap, + too_long, + null_with_length, + not_utf8, + } + } + + /// Copies the string `s` holds. The length is checked against the cap + /// before any byte is read, and `s.ptr` is read only when `s.len` is + /// nonzero, because `slice::from_raw_parts` with a NULL pointer is + /// undefined behavior even at length 0. + /// + /// # Safety + /// When `s.len` is nonzero, `s.ptr` must be valid for reads of `s.len` + /// bytes for the duration of this call. + pub(crate) unsafe fn read(&self, s: RvStr) -> Result { + if s.len > self.cap { + return Err(self.too_long); + } + if s.len == 0 { + return Ok(String::new()); + } + if s.ptr.is_null() { + return Err(self.null_with_length); + } + // SAFETY: `s.ptr` is not NULL and `s.len` is at most the cap, and + // the caller guarantees `s.ptr` is valid for `s.len` bytes. + let bytes = unsafe { slice::from_raw_parts(s.ptr.cast::(), s.len) }; + std::str::from_utf8(bytes) + .map(str::to_owned) + .map_err(|_| self.not_utf8) + } +} + +/// One struct a Plugin hands a function, and what each way of handing it +/// over wrongly is called. +pub(crate) struct Shape { + null: &'static str, + misaligned: &'static str, + short: &'static str, +} + +/// The [`Shape`] of the struct `$struct`, which the function `$function` +/// takes and calls its `$what`. +macro_rules! shape { + ($function:literal, $what:literal, $struct:literal) => { + $crate::session::Shape::new( + concat!($function, " was handed a NULL ", $what), + concat!($function, " was handed a misaligned ", $what), + concat!("the ", $what, "'s size is shorter than ", $struct), + ) + }; +} +pub(crate) use shape; + +/// What a `reserved` field that holds anything but zero is called. +pub(crate) const RESERVED: &str = "the info's reserved field is not zero"; + +impl Shape { + /// A shape with the name of each breach. + pub(crate) const fn new( + null: &'static str, + misaligned: &'static str, + short: &'static str, + ) -> Shape { + Shape { + null, + misaligned, + short, + } + } + + /// The struct `at` points at, once it is known to be whole. + /// + /// # Safety + /// When `at` is not NULL and is aligned, it must be readable as a + /// struct that begins with a `u32` saying how much of it the Plugin + /// wrote, and readable as a whole `T` when that says so, for the + /// lifetime `'a`. + pub(crate) unsafe fn read<'a, T>(&self, at: *const T) -> Result<&'a T, &'static str> { + if at.is_null() { + return Err(self.null); + } + if !at.is_aligned() { + return Err(self.misaligned); + } + // SAFETY: `at` is not NULL and is aligned, and `size` is the first + // field of a struct the Plugin wrote, so it is readable on its own. + let size = unsafe { at.cast::().read() }; + if (size as usize) < size_of::() { + return Err(self.short); + } + // SAFETY: `size` says the Plugin wrote a whole `T`. + Ok(unsafe { &*at }) + } +} + +/// One struct a Plugin hands a function to fill, and what each way of +/// handing it over wrongly is called. +pub(crate) struct Out { + null: &'static str, + misaligned: &'static str, +} + +/// The [`Out`] of the struct the function `$function` fills and calls +/// its `$what`. +macro_rules! out { + ($function:literal, $what:literal) => { + $crate::session::Out::new( + concat!($function, " was handed a NULL ", $what, " pointer"), + concat!($function, " was handed a misaligned ", $what, " pointer"), + ) + }; +} +pub(crate) use out; + +impl Out { + /// An out struct with the name of each breach. + pub(crate) const fn new(null: &'static str, misaligned: &'static str) -> Out { + Out { null, misaligned } + } + + /// `at`, once it is known to be a place a `T` can be written to. It + /// reads nothing. + pub(crate) fn check(&self, at: *mut T) -> Result<*mut T, &'static str> { + if at.is_null() { + return Err(self.null); + } + if !at.is_aligned() { + return Err(self.misaligned); + } + Ok(at) + } +} + +/// `size_of::()` as the `u32` a contract struct's `size` field holds. +#[expect( + clippy::cast_possible_truncation, + reason = "the assertion bounds the size to a u32" +)] +pub(crate) const fn size_field() -> u32 { + assert!( + size_of::() <= u32::MAX as usize, + "a contract struct's size fits its u32 size field" + ); + size_of::() as u32 +} + +/// `s` as the contract's pointer and length. +pub(crate) const fn rv_str(s: &str) -> RvStr { + RvStr { + ptr: s.as_ptr().cast(), + len: s.len(), + } +} diff --git a/crates/harness-internal/test-plugin/Cargo.toml b/crates/harness-internal/test-plugin/Cargo.toml index de51be5aa..418d60da3 100644 --- a/crates/harness-internal/test-plugin/Cargo.toml +++ b/crates/harness-internal/test-plugin/Cargo.toml @@ -15,13 +15,16 @@ crate-type = ["cdylib", "rlib"] # and this fixture proves it. # Only the end-to-end tests in `tests/it` use these: the loader to insert -# the built library, the Plugin contract crate for `PackRegistry::new`, and +# the built library, the Plugin contract crate for `PackRegistry::new`, # `libloading` to open the library the loader already holds and read its -# counters. +# counters, and the Harness facade with tokio to run a prompt that calls +# the fixture's tools. [dev-dependencies] harness-plugin-loader.workspace = true libloading.workspace = true +runvil.workspace = true runvil-plugin.workspace = true +tokio = { workspace = true, features = ["macros", "rt", "time"] } # `RTLD_NOLOAD`, so the end-to-end tests can ask whether a library is still # loaded without loading it. diff --git a/crates/harness-internal/test-plugin/src/lib-tests.rs b/crates/harness-internal/test-plugin/src/lib-tests.rs index 5b274396d..fb4c70074 100644 --- a/crates/harness-internal/test-plugin/src/lib-tests.rs +++ b/crates/harness-internal/test-plugin/src/lib-tests.rs @@ -2,7 +2,7 @@ //! point. The layout and number tests pin the same values as //! `runvil-plugin-sys`, because this crate cannot import it. The verb //! tests call `runvil_plugin` directly with a hand-built `RvHost` table -//! and `RvLoadArgs`, whose `describe_plugin` records what it was given. +//! and arguments structs, whose functions record what they were given. use std::cell::{Cell, RefCell}; use std::ptr; @@ -13,9 +13,6 @@ use std::mem::offset_of; use super::*; -/// Verb 3, which the contract reserves for a later version. -const RV_PACK_NEW: RvVerb = 3; - /// What one `describe_plugin` call was handed, copied. struct Described { ctx: *mut c_void, @@ -30,14 +27,29 @@ thread_local! { static DESCRIBED: RefCell> = const { RefCell::new(Vec::new()) }; /// The status `describe_plugin` answers with. static DESCRIBE_STATUS: Cell = const { Cell::new(RV_OK) }; + /// The name and description of every Pack this test's thread inserted. + static INSERTED: RefCell> = const { RefCell::new(Vec::new()) }; + /// The status `insert_pack` answers with. + static INSERT_STATUS: Cell = const { Cell::new(RV_OK) }; + /// The name, wire name, and parameters of every tool this test's + /// thread added. + static ADDED: RefCell> = const { RefCell::new(Vec::new()) }; + /// How many `add_tool` calls answer `RV_OK` before one answers + /// `RV_ERR_MISUSE`. + static ADDS_ALLOWED: Cell = const { Cell::new(usize::MAX) }; + /// The kind and text of every result this test's thread finished with. + static FINISHED: RefCell> = const { RefCell::new(Vec::new()) }; /// How many times the Plugin called `refuse`, which it never should. static REFUSALS: Cell = const { Cell::new(0) }; } /// Copies the bytes of `text` into a `String`. fn copy(text: RvStr) -> String { + if text.len == 0 { + return String::new(); + } // SAFETY: the entry point under test hands over a string that lives - // for the whole `describe_plugin` call and holds `len` bytes. + // for the whole call and holds `len` bytes. let bytes = unsafe { slice::from_raw_parts(text.ptr.cast::(), text.len) }; String::from_utf8_lossy(bytes).into_owned() } @@ -58,13 +70,201 @@ unsafe extern "C" fn describe_plugin(ctx: *mut c_void, info: *const RvPluginInfo DESCRIBE_STATUS.get() } -/// The load arguments' `refuse`: counts itself, because this Plugin never -/// refuses. +/// The load arguments' `insert_pack`: records the Pack and answers with +/// `INSERT_STATUS`. +unsafe extern "C" fn insert_pack(ctx: *mut c_void, info: *const RvPackInfo) -> RvStatus { + assert_eq!(ctx, test_ctx(), "insert_pack is handed the load's own ctx"); + // SAFETY: the entry point under test passes a live `RvPackInfo`. + let info = unsafe { &*info }; + assert_eq!(info.size, size_of_u32::()); + assert_eq!(info.reserved, 0); + INSERTED.with_borrow_mut(|all| all.push((copy(info.name), copy(info.description)))); + INSERT_STATUS.get() +} + +/// The `refuse` of every arguments struct: counts itself, because this +/// Plugin never refuses. unsafe extern "C" fn refuse(_ctx: *mut c_void, _reason: RvStr) -> RvStatus { REFUSALS.set(REFUSALS.get() + 1); RV_ERR_REFUSED } +/// The Pack arguments' `add_tool`: records the tool, and answers `RV_OK` +/// until `ADDS_ALLOWED` runs out. +unsafe extern "C" fn add_tool(ctx: *mut c_void, info: *const RvToolInfo) -> RvStatus { + assert_eq!(ctx, test_ctx(), "add_tool is handed the call's own ctx"); + // SAFETY: the entry point under test passes a live `RvToolInfo`. + let info = unsafe { &*info }; + assert_eq!(info.size, size_of_u32::()); + assert_eq!(info.reserved, 0); + assert!(info.description.len > 0, "each tool has a description"); + if ADDS_ALLOWED.get() == 0 { + return RV_ERR_MISUSE; + } + ADDS_ALLOWED.set(ADDS_ALLOWED.get() - 1); + ADDED.with_borrow_mut(|all| { + all.push((copy(info.name), copy(info.wire_name), copy(info.parameters))); + }); + RV_OK +} + +/// The step arguments' `finish`: records the result. +unsafe extern "C" fn finish(ctx: *mut c_void, result: *const RvToolResult) -> RvStatus { + assert_eq!(ctx, test_ctx(), "finish is handed the step's own ctx"); + // SAFETY: the entry point under test passes a live `RvToolResult`. + let result = unsafe { &*result }; + assert_eq!(result.size, size_of_u32::()); + FINISHED.with_borrow_mut(|all| all.push((result.kind, copy(result.text)))); + RV_OK +} + +/// The one file the test Host serves, and its text. +const FILE: &str = "/notes/a.md"; +const FILE_TEXT: &str = "hello"; +/// The one directory the test Host serves, which holds `a.md`, the +/// directory `sub`, and the link `link`. +const DIRECTORY: &str = "/notes"; +/// A path the test Host answers with a status no file function returns. +const BROKEN: &str = "/broken"; +/// The kind every failure of the test Host carries. +const NOT_FOUND: RvFileErrorKind = 1; + +/// The entries the test Host lists for [`DIRECTORY`]. +const ENTRIES: [RvDirEntry; 3] = [ + RvDirEntry { + name: RvStr { + ptr: "a.md".as_ptr().cast(), + len: 4, + }, + stat: RvStat { + file_type: RV_FILE_TYPE_FILE, + reserved: 0, + len: 5, + }, + }, + RvDirEntry { + name: RvStr { + ptr: "sub".as_ptr().cast(), + len: 3, + }, + stat: RvStat { + file_type: RV_FILE_TYPE_DIRECTORY, + reserved: 0, + len: 0, + }, + }, + RvDirEntry { + name: RvStr { + ptr: "link".as_ptr().cast(), + len: 4, + }, + stat: RvStat { + file_type: RV_FILE_TYPE_SYMLINK, + reserved: 0, + len: 0, + }, + }, +]; + +/// Fills `error` with a failure that says `path` was not found, and +/// answers `RV_ERR_FILE`, or the odd status 77 for [`BROKEN`]. +fn fail(path: &str, error: *mut RvFileError) -> RvStatus { + if path == BROKEN { + return 77; + } + let failure = RvFileError { + kind: NOT_FOUND, + reserved: 0, + text: rv_str("nothing is there"), + }; + // SAFETY: the entry point under test passes a live `RvFileError`. + unsafe { error.write(failure) }; + RV_ERR_FILE +} + +/// The step arguments' `file_stat`: knows [`FILE`] and [`DIRECTORY`]. +unsafe extern "C" fn file_stat( + ctx: *mut c_void, + path: RvStr, + stat: *mut RvStat, + error: *mut RvFileError, +) -> RvStatus { + assert_eq!(ctx, test_ctx(), "file_stat is handed the step's own ctx"); + let path = copy(path); + let found = match path.as_str() { + FILE => ENTRIES[0].stat, + DIRECTORY => ENTRIES[1].stat, + "/link" => ENTRIES[2].stat, + "/pipe" => RvStat { + file_type: 0, + reserved: 0, + len: 0, + }, + _ => return fail(&path, error), + }; + // SAFETY: the entry point under test passes a live `RvStat`. + unsafe { stat.write(found) }; + RV_OK +} + +/// The step arguments' `file_list`: lists [`DIRECTORY`], and the root as +/// an empty directory. +unsafe extern "C" fn file_list( + ctx: *mut c_void, + path: RvStr, + entries: *mut RvDirEntries, + error: *mut RvFileError, +) -> RvStatus { + assert_eq!(ctx, test_ctx(), "file_list is handed the step's own ctx"); + let path = copy(path); + // A borrow of a constant is promoted to a static, so the entries + // outlive this function, as the contract requires. + let all: &'static [RvDirEntry] = &ENTRIES; + let listed = match path.as_str() { + DIRECTORY => RvDirEntries { + ptr: all.as_ptr(), + len: all.len(), + }, + "/" => RvDirEntries { + ptr: ptr::null(), + len: 0, + }, + _ => return fail(&path, error), + }; + // SAFETY: the entry point under test passes a live `RvDirEntries`. + unsafe { entries.write(listed) }; + RV_OK +} + +/// The step arguments' `file_read`: reads [`FILE`], and records the range +/// it was asked for. +unsafe extern "C" fn file_read( + ctx: *mut c_void, + path: RvStr, + offset: u64, + len: u64, + bytes: *mut RvBytes, + error: *mut RvFileError, +) -> RvStatus { + assert_eq!(ctx, test_ctx(), "file_read is handed the step's own ctx"); + assert_eq!( + (offset, len), + (0, u64::MAX), + "the tool reads the whole file" + ); + let path = copy(path); + if path != FILE { + return fail(&path, error); + } + let read = RvBytes { + ptr: FILE_TEXT.as_ptr(), + len: FILE_TEXT.len(), + }; + // SAFETY: the entry point under test passes a live `RvBytes`. + unsafe { bytes.write(read) }; + RV_OK +} + /// A table that reports `size` and version `api_major`.`api_minor`. fn host_with(size: u32, api_major: u32, api_minor: u32) -> RvHost { RvHost { @@ -77,7 +277,7 @@ fn host_with(size: u32, api_major: u32, api_minor: u32) -> RvHost { /// A table the way the loader builds it. fn host() -> RvHost { - host_with(size_of_u32::(), 0, 2) + host_with(size_of_u32::(), 0, 4) } /// The `ctx` the tests hand the Plugin. The Plugin never reads through it, @@ -94,6 +294,7 @@ fn args_with(size: u32) -> RvLoadArgs { ctx: test_ctx(), refuse, describe_plugin, + insert_pack, } } @@ -102,9 +303,37 @@ fn args() -> RvLoadArgs { args_with(size_of_u32::()) } +/// Pack arguments that report `size` and name the Pack `pack`. +fn pack_args(size: u32, pack: &str) -> RvPackNewArgs { + RvPackNewArgs { + size, + reserved: 0, + ctx: test_ctx(), + pack: rv_str(pack), + refuse, + add_tool, + } +} + +/// Step arguments that report `size`, for the tool `tool` with +/// `arguments`. +fn step_args(size: u32, tool: &str, arguments: &str) -> RvToolCallArgs { + RvToolCallArgs { + size, + reserved: 0, + ctx: test_ctx(), + tool: rv_str(tool), + arguments: rv_str(arguments), + finish, + file_stat, + file_list, + file_read, + } +} + /// A call that sends `verb` and carries `plugin`, with `host` as its table /// and `args` as its arguments. -fn call_for(verb: RvVerb, host: &RvHost, args: *const RvLoadArgs, plugin: *mut c_void) -> RvCall { +fn call_for(verb: RvVerb, host: &RvHost, args: *const T, plugin: *mut c_void) -> RvCall { RvCall { size: size_of_u32::(), verb, @@ -112,6 +341,7 @@ fn call_for(verb: RvVerb, host: &RvHost, args: *const RvLoadArgs, plugin: *mut c plugin, pack_run: ptr::null_mut(), args: args.cast(), + tool_call: ptr::null_mut(), } } @@ -150,9 +380,47 @@ fn loaded(host: &RvHost) -> RvCall { fn unload(call: &mut RvCall) -> RvStatus { call.verb = RV_PLUGIN_UNLOAD; call.args = ptr::null(); + call.pack_run = ptr::null_mut(); + call.tool_call = ptr::null_mut(); + send(call) +} + +/// Sends `RV_PACK_NEW` for the fixture's Pack on the loaded `call`, which +/// then carries the Pack instance. +fn new_pack(call: &mut RvCall) -> RvStatus { + let args = pack_args(size_of_u32::(), PACK); + call.verb = RV_PACK_NEW; + call.args = (&raw const args).cast(); send(call) } +/// Sends `RV_PACK_DELETE` for the Pack instance `call` carries. +fn delete_pack(call: &mut RvCall) -> RvStatus { + call.verb = RV_PACK_DELETE; + call.args = ptr::null(); + let status = send(call); + call.pack_run = ptr::null_mut(); + status +} + +/// Sends one `RV_TOOL_CALL` step of `tool` with `arguments` on the Pack +/// instance `call` carries. +fn step(call: &mut RvCall, tool: &str, arguments: &str) -> RvStatus { + let args = step_args(size_of_u32::(), tool, arguments); + call.verb = RV_TOOL_CALL; + call.args = (&raw const args).cast(); + send(call) +} + +/// Sends `RV_TOOL_CANCEL` for the call state `call` carries. +fn cancel(call: &mut RvCall) -> RvStatus { + call.verb = RV_TOOL_CANCEL; + call.args = ptr::null(); + let status = send(call); + call.tool_call = ptr::null_mut(); + status +} + /// How many times the Plugin called `describe_plugin`. fn describes() -> usize { DESCRIBED.with_borrow(Vec::len) @@ -163,6 +431,11 @@ fn live_instances() -> isize { LIVE_INSTANCES.get() } +/// The results this test's thread finished with, in order. +fn finished() -> Vec<(RvResultKind, String)> { + FINISHED.with_borrow(Clone::clone) +} + #[test] #[cfg(target_pointer_width = "64")] fn a_string_is_a_pointer_then_a_length() { @@ -173,12 +446,57 @@ fn a_string_is_a_pointer_then_a_length() { #[test] #[cfg(target_pointer_width = "64")] -fn the_plugin_info_keeps_the_layout_the_contract_pins() { +fn the_infos_and_the_result_keep_the_layout_the_contract_pins() { assert_eq!(size_of::(), 40); assert_eq!(offset_of!(RvPluginInfo, size), 0); assert_eq!(offset_of!(RvPluginInfo, reserved), 4); assert_eq!(offset_of!(RvPluginInfo, name), 8); assert_eq!(offset_of!(RvPluginInfo, version), 24); + + assert_eq!(size_of::(), 40); + assert_eq!(offset_of!(RvPackInfo, size), 0); + assert_eq!(offset_of!(RvPackInfo, reserved), 4); + assert_eq!(offset_of!(RvPackInfo, name), 8); + assert_eq!(offset_of!(RvPackInfo, description), 24); + + assert_eq!(size_of::(), 72); + assert_eq!(offset_of!(RvToolInfo, size), 0); + assert_eq!(offset_of!(RvToolInfo, reserved), 4); + assert_eq!(offset_of!(RvToolInfo, name), 8); + assert_eq!(offset_of!(RvToolInfo, wire_name), 24); + assert_eq!(offset_of!(RvToolInfo, description), 40); + assert_eq!(offset_of!(RvToolInfo, parameters), 56); + + assert_eq!(size_of::(), 24); + assert_eq!(offset_of!(RvToolResult, size), 0); + assert_eq!(offset_of!(RvToolResult, kind), 4); + assert_eq!(offset_of!(RvToolResult, text), 8); +} + +#[test] +#[cfg(target_pointer_width = "64")] +fn what_a_file_function_hands_back_keeps_the_layout_the_contract_pins() { + assert_eq!(size_of::(), 16); + assert_eq!(offset_of!(RvBytes, ptr), 0); + assert_eq!(offset_of!(RvBytes, len), 8); + + assert_eq!(size_of::(), 16); + assert_eq!(offset_of!(RvStat, file_type), 0); + assert_eq!(offset_of!(RvStat, reserved), 4); + assert_eq!(offset_of!(RvStat, len), 8); + + assert_eq!(size_of::(), 32); + assert_eq!(offset_of!(RvDirEntry, name), 0); + assert_eq!(offset_of!(RvDirEntry, stat), 16); + + assert_eq!(size_of::(), 16); + assert_eq!(offset_of!(RvDirEntries, ptr), 0); + assert_eq!(offset_of!(RvDirEntries, len), 8); + + assert_eq!(size_of::(), 24); + assert_eq!(offset_of!(RvFileError, kind), 0); + assert_eq!(offset_of!(RvFileError, reserved), 4); + assert_eq!(offset_of!(RvFileError, text), 8); } #[test] @@ -193,45 +511,77 @@ fn the_host_table_keeps_the_layout_the_contract_pins() { #[test] #[cfg(target_pointer_width = "64")] -fn the_load_arguments_keep_the_layout_the_contract_pins() { - assert_eq!(size_of::(), 32); +fn the_arguments_structs_keep_the_layout_the_contract_pins() { + assert_eq!(size_of::(), 40); assert_eq!(offset_of!(RvLoadArgs, size), 0); assert_eq!(offset_of!(RvLoadArgs, reserved), 4); assert_eq!(offset_of!(RvLoadArgs, ctx), 8); assert_eq!(offset_of!(RvLoadArgs, refuse), 16); assert_eq!(offset_of!(RvLoadArgs, describe_plugin), 24); + assert_eq!(offset_of!(RvLoadArgs, insert_pack), 32); + + assert_eq!(size_of::(), 48); + assert_eq!(offset_of!(RvPackNewArgs, size), 0); + assert_eq!(offset_of!(RvPackNewArgs, reserved), 4); + assert_eq!(offset_of!(RvPackNewArgs, ctx), 8); + assert_eq!(offset_of!(RvPackNewArgs, pack), 16); + assert_eq!(offset_of!(RvPackNewArgs, refuse), 32); + assert_eq!(offset_of!(RvPackNewArgs, add_tool), 40); + + assert_eq!(size_of::(), 80); + assert_eq!(offset_of!(RvToolCallArgs, size), 0); + assert_eq!(offset_of!(RvToolCallArgs, reserved), 4); + assert_eq!(offset_of!(RvToolCallArgs, ctx), 8); + assert_eq!(offset_of!(RvToolCallArgs, tool), 16); + assert_eq!(offset_of!(RvToolCallArgs, arguments), 32); + assert_eq!(offset_of!(RvToolCallArgs, finish), 48); + assert_eq!(offset_of!(RvToolCallArgs, file_stat), 56); + assert_eq!(offset_of!(RvToolCallArgs, file_list), 64); + assert_eq!(offset_of!(RvToolCallArgs, file_read), 72); } #[test] #[cfg(target_pointer_width = "64")] fn the_call_keeps_the_layout_the_contract_pins() { - assert_eq!(size_of::(), 40); + assert_eq!(size_of::(), 48); assert_eq!(offset_of!(RvCall, size), 0); assert_eq!(offset_of!(RvCall, verb), 4); assert_eq!(offset_of!(RvCall, host), 8); assert_eq!(offset_of!(RvCall, plugin), 16); assert_eq!(offset_of!(RvCall, pack_run), 24); assert_eq!(offset_of!(RvCall, args), 32); + assert_eq!(offset_of!(RvCall, tool_call), 40); } #[test] -fn the_status_codes_keep_their_numbers() { +fn the_status_codes_and_the_result_kinds_keep_their_numbers() { assert_eq!(RV_OK, 0); assert_eq!(RV_ERR_MISUSE, 4); assert_eq!(RV_ERR_REFUSED, 5); assert_eq!(RV_ERR_UNSUPPORTED, 6); + assert_eq!(RV_PENDING, 7); + assert_eq!(RV_ERR_FILE, 8); + assert_eq!(RV_RESULT_TRUSTED, 1); + assert_eq!(RV_RESULT_OTHER, 5); + assert_eq!(RV_FILE_TYPE_FILE, 1); + assert_eq!(RV_FILE_TYPE_DIRECTORY, 2); + assert_eq!(RV_FILE_TYPE_SYMLINK, 3); } #[test] fn the_verbs_and_the_version_keep_their_numbers() { assert_eq!(RV_PLUGIN_LOAD, 1); assert_eq!(RV_PLUGIN_UNLOAD, 2); + assert_eq!(RV_PACK_NEW, 3); + assert_eq!(RV_PACK_DELETE, 4); + assert_eq!(RV_TOOL_CALL, 5); + assert_eq!(RV_TOOL_CANCEL, 6); assert_eq!(RV_API_MAJOR, 0); - assert_eq!(RV_API_MINOR, 2); + assert_eq!(RV_API_MINOR, 4); } #[test] -fn a_load_describes_test_plugin_once_through_its_ctx_and_sets_the_instance() { +fn a_load_describes_test_plugin_once_inserts_its_pack_and_sets_the_instance() { let host = host(); let mut call = loaded(&host); assert!( @@ -251,6 +601,16 @@ fn a_load_describes_test_plugin_once_through_its_ctx_and_sets_the_instance() { "describe_plugin is handed the load's own ctx" ); }); + INSERTED.with_borrow(|all| { + assert_eq!( + *all, + [( + "fixture/test".to_owned(), + "The loader's test fixture".to_owned() + )], + "the load inserts its one Pack exactly once" + ); + }); assert_eq!(REFUSALS.get(), 0, "the Plugin never refuses"); assert_eq!(live_instances(), 1, "a successful load keeps its instance"); assert_eq!(unload(&mut call), RV_OK); @@ -286,7 +646,7 @@ fn an_unload_with_no_instance_is_misuse() { #[test] fn the_reserved_and_unknown_verbs_are_unsupported() { - for verb in [0, RV_PACK_NEW, 4, 5, 6, 7, 8, u32::MAX] { + for verb in [0, 7, 8, u32::MAX] { let (status, call) = send_verb(verb, &host(), ptr::null()); assert_eq!(status, RV_ERR_UNSUPPORTED, "verb {verb}"); assert!(call.plugin.is_null(), "verb {verb} sets no instance"); @@ -295,20 +655,28 @@ fn the_reserved_and_unknown_verbs_are_unsupported() { } #[test] -fn a_host_reporting_version_0_1_is_unsupported_and_leaves_no_instance() { - let (status, call) = send_load_with(&host_with(size_of_u32::(), 0, 1)); +fn a_host_reporting_version_0_2_is_unsupported_and_leaves_no_instance() { + let (status, call) = send_load_with(&host_with(size_of_u32::(), 0, 2)); assert_eq!(status, RV_ERR_UNSUPPORTED); assert!(call.plugin.is_null()); - assert_eq!(describes(), 0, "a 0.1 Host is never called back"); + assert_eq!(describes(), 0, "a 0.2 Host is never called back"); assert_eq!(live_instances(), 0); } #[test] -fn a_table_that_is_too_short_or_not_version_0_2_is_unsupported_and_leaves_no_instance() { +fn a_table_that_is_too_short_or_not_version_0_4_is_unsupported_and_leaves_no_instance() { let full = size_of_u32::(); - let short = [0, 4, 8, 12, full - 1].map(|size| (size, 0, 2)); - let versions = [(1, 2), (2, 2), (u32::MAX, 2), (0, 0), (0, 3), (0, u32::MAX)] - .map(|(major, minor)| (full, major, minor)); + let short = [0, 4, 8, 12, full - 1].map(|size| (size, 0, 4)); + let versions = [ + (1, 4), + (2, 4), + (u32::MAX, 4), + (0, 0), + (0, 3), + (0, 5), + (0, u32::MAX), + ] + .map(|(major, minor)| (full, major, minor)); for (size, api_major, api_minor) in short.into_iter().chain(versions) { let (status, call) = send_load_with(&host_with(size, api_major, api_minor)); assert_eq!( @@ -326,7 +694,7 @@ fn a_table_that_is_too_short_or_not_version_0_2_is_unsupported_and_leaves_no_ins #[test] fn a_table_longer_than_the_one_the_fixture_knows_still_loads() { - let host = host_with(size_of_u32::() + 32, 0, 2); + let host = host_with(size_of_u32::() + 32, 0, 4); let mut call = loaded(&host); assert!(!call.plugin.is_null()); assert_eq!(unload(&mut call), RV_OK); @@ -344,7 +712,7 @@ fn a_load_with_null_args_is_misuse_and_leaves_no_instance() { #[test] fn short_load_arguments_are_unsupported_and_leave_no_instance() { let full = size_of_u32::(); - for size in [0, 4, 8, 16, 24, full - 1] { + for size in [0, 4, 8, 16, 24, 32, full - 1] { let args = args_with(size); let (status, call) = send_verb(RV_PLUGIN_LOAD, &host(), &raw const args); assert_eq!(status, RV_ERR_UNSUPPORTED, "load arguments of size {size}"); @@ -358,10 +726,10 @@ fn short_load_arguments_are_unsupported_and_leave_no_instance() { } #[test] -fn a_host_that_is_not_0_2_is_rejected_before_null_args_are_noticed() { +fn a_host_that_is_not_0_4_is_rejected_before_null_args_are_noticed() { let (status, _call) = send_verb( RV_PLUGIN_LOAD, - &host_with(size_of_u32::(), 0, 1), + &host_with(size_of_u32::(), 0, 2), ptr::null(), ); assert_eq!( @@ -388,3 +756,272 @@ fn a_describe_plugin_that_fails_fails_the_load_with_its_status_and_no_instance() } assert_eq!(describes(), 2, "each failing load still described once"); } + +#[test] +fn an_insert_pack_that_fails_fails_the_load_with_its_status_and_no_instance() { + INSERT_STATUS.set(RV_ERR_MISUSE); + let (status, call) = send_load_with(&host()); + assert_eq!( + status, RV_ERR_MISUSE, + "the load returns insert_pack's status" + ); + assert!(call.plugin.is_null()); + assert_eq!( + live_instances(), + 0, + "a failing load frees the instance it made" + ); +} + +#[test] +fn a_pack_instance_adds_its_six_tools_and_is_deleted_once() { + let host = host(); + let mut call = loaded(&host); + assert_eq!(new_pack(&mut call), RV_OK); + assert!(!call.pack_run.is_null(), "a successful call sets pack_run"); + ADDED.with_borrow(|all| { + let tools: Vec<(&str, &str, &str)> = all + .iter() + .map(|(name, wire, parameters)| (name.as_str(), wire.as_str(), parameters.as_str())) + .collect(); + assert_eq!( + tools, + [ + ("hello", "", NO_PARAMETERS), + ("steps", "", NO_PARAMETERS), + ("never", "", NO_PARAMETERS), + ("stat", "", PATH_PARAMETERS), + ("list", "", PATH_PARAMETERS), + ("read", "", PATH_PARAMETERS), + ] + ); + }); + assert_eq!(delete_pack(&mut call), RV_OK); + assert_eq!(REFUSALS.get(), 0, "the Plugin never refuses"); + assert_eq!(unload(&mut call), RV_OK); +} + +#[test] +fn a_pack_instance_whose_tool_is_rejected_is_freed_and_never_handed_over() { + let host = host(); + let mut call = loaded(&host); + ADDS_ALLOWED.set(1); + assert_eq!(new_pack(&mut call), RV_ERR_MISUSE); + assert!(call.pack_run.is_null()); + assert_eq!(unload(&mut call), RV_OK); +} + +#[test] +fn a_pack_verb_with_the_wrong_arguments_or_no_instance_makes_nothing() { + let host = host(); + let mut call = loaded(&host); + call.verb = RV_PACK_NEW; + call.args = ptr::null(); + assert_eq!( + send(&mut call), + RV_ERR_MISUSE, + "RV_PACK_NEW needs its arguments" + ); + + let short = pack_args(size_of_u32::() - 1, PACK); + call.args = (&raw const short).cast(); + assert_eq!(send(&mut call), RV_ERR_UNSUPPORTED, "short arguments"); + + let other = pack_args(size_of_u32::(), "fixture/other"); + call.args = (&raw const other).cast(); + assert_eq!(send(&mut call), RV_ERR_MISUSE, "a Pack it never inserted"); + + assert!(call.pack_run.is_null()); + assert_eq!(delete_pack(&mut call), RV_ERR_MISUSE, "nothing to delete"); + assert_eq!(unload(&mut call), RV_OK); +} + +#[test] +fn the_hello_tool_finishes_in_one_step_with_its_arguments() { + let host = host(); + let mut call = loaded(&host); + assert_eq!(new_pack(&mut call), RV_OK); + assert_eq!(step(&mut call, "hello", r#"{"a":1}"#), RV_OK); + assert_eq!( + finished(), + [(RV_RESULT_TRUSTED, r#"hello {"a":1}"#.to_owned())] + ); + assert!( + call.tool_call.is_null(), + "a call that ended leaves no state" + ); + assert_eq!(delete_pack(&mut call), RV_OK); + assert_eq!(unload(&mut call), RV_OK); +} + +#[test] +fn the_steps_tool_is_pending_twice_and_finishes_in_its_third_step() { + let host = host(); + let mut call = loaded(&host); + assert_eq!(new_pack(&mut call), RV_OK); + assert_eq!(step(&mut call, "steps", "{}"), RV_PENDING); + let state = call.tool_call; + assert!(!state.is_null(), "a pending call keeps its state"); + assert_eq!(step(&mut call, "steps", "{}"), RV_PENDING); + assert_eq!(call.tool_call, state, "the same state comes back"); + assert_eq!(finished(), [], "a pending step finishes nothing"); + assert_eq!(step(&mut call, "steps", "{}"), RV_OK); + assert_eq!(finished(), [(RV_RESULT_TRUSTED, "3 steps".to_owned())]); + assert!(call.tool_call.is_null(), "the last step frees the state"); + assert_eq!(delete_pack(&mut call), RV_OK); + assert_eq!(unload(&mut call), RV_OK); +} + +#[test] +fn the_never_tool_stays_pending_until_a_cancel_frees_its_state() { + let host = host(); + let mut call = loaded(&host); + assert_eq!(new_pack(&mut call), RV_OK); + for _ in 0..5 { + assert_eq!(step(&mut call, "never", "{}"), RV_PENDING); + } + assert!(!call.tool_call.is_null()); + assert_eq!(cancel(&mut call), RV_OK); + assert_eq!( + cancel(&mut call), + RV_OK, + "a cancel with no state frees nothing" + ); + assert_eq!(finished(), []); + assert_eq!(delete_pack(&mut call), RV_OK); + assert_eq!(unload(&mut call), RV_OK); +} + +#[test] +fn a_tool_the_pack_does_not_have_fails_the_call() { + let host = host(); + let mut call = loaded(&host); + assert_eq!(new_pack(&mut call), RV_OK); + assert_eq!(step(&mut call, "absent", "{}"), RV_OK); + assert_eq!( + finished(), + [( + RV_RESULT_OTHER, + "test-plugin has no tool named absent".to_owned() + )] + ); + assert_eq!(delete_pack(&mut call), RV_OK); + assert_eq!(unload(&mut call), RV_OK); +} + +/// Runs one step of the file tool `tool` on `path`, and returns the text +/// it finished with, which must be a trusted result. +fn file_tool(tool: &str, path: &str) -> String { + let host = host(); + let mut call = loaded(&host); + assert_eq!(new_pack(&mut call), RV_OK); + let arguments = format!(r#"{{"path":"{path}"}}"#); + assert_eq!(step(&mut call, tool, &arguments), RV_OK, "{tool} {path}"); + assert!(call.tool_call.is_null(), "a file tool keeps no state"); + assert_eq!(delete_pack(&mut call), RV_OK); + assert_eq!(unload(&mut call), RV_OK); + let mut finished = finished(); + assert_eq!(finished.len(), 1, "{tool} {path} finishes once"); + let (kind, text) = finished.remove(0); + assert_eq!(kind, RV_RESULT_TRUSTED, "{tool} {path}"); + text +} + +#[test] +fn the_stat_tool_words_what_file_stat_filled() { + assert_eq!(file_tool("stat", FILE), "file 5"); +} + +#[test] +fn the_stat_tool_names_a_directory_a_link_and_anything_else() { + for (path, said) in [ + (DIRECTORY, "directory"), + ("/link", "symlink"), + ("/pipe", "other"), + ] { + FINISHED.with_borrow_mut(Vec::clear); + assert_eq!(file_tool("stat", path), said, "{path}"); + } +} + +#[test] +fn the_list_tool_joins_the_names_and_marks_each_directory() { + assert_eq!(file_tool("list", DIRECTORY), "a.md,sub/,link"); +} + +#[test] +fn the_list_tool_answers_nothing_for_an_empty_directory() { + assert_eq!(file_tool("list", "/"), ""); +} + +#[test] +fn the_read_tool_answers_the_text_of_the_whole_file() { + assert_eq!(file_tool("read", FILE), FILE_TEXT); +} + +#[test] +fn a_file_function_that_fails_ends_the_call_with_a_text_that_says_so() { + for tool in ["stat", "list", "read"] { + FINISHED.with_borrow_mut(Vec::clear); + assert_eq!( + file_tool(tool, "/absent"), + "failed (1): nothing is there", + "{tool}" + ); + } +} + +#[test] +fn a_file_function_that_returns_another_status_is_named_with_it() { + assert_eq!( + file_tool("stat", BROKEN), + "the file function returned status 77" + ); +} + +#[test] +fn a_file_tool_without_a_path_asks_for_the_empty_path() { + let host = host(); + let mut call = loaded(&host); + assert_eq!(new_pack(&mut call), RV_OK); + assert_eq!(step(&mut call, "stat", "{}"), RV_OK); + assert_eq!( + finished(), + [(RV_RESULT_TRUSTED, "failed (1): nothing is there".to_owned())] + ); + assert_eq!(delete_pack(&mut call), RV_OK); + assert_eq!(unload(&mut call), RV_OK); +} + +#[test] +fn the_path_is_the_text_between_the_quotes_after_its_key() { + assert_eq!(path_of(r#"{"path":"/notes/a.md"}"#), "/notes/a.md"); + assert_eq!(path_of(r#"{"limit":3,"path":"a b"}"#), "a b"); + assert_eq!(path_of(r#"{"path":""}"#), ""); + assert_eq!(path_of("{}"), ""); + assert_eq!(path_of(r#"{"path":"unclosed"#), ""); +} + +#[test] +fn a_step_with_the_wrong_arguments_ends_the_call_and_frees_its_state() { + let host = host(); + let mut call = loaded(&host); + assert_eq!(new_pack(&mut call), RV_OK); + assert_eq!(step(&mut call, "never", "{}"), RV_PENDING); + call.verb = RV_TOOL_CALL; + call.args = ptr::null(); + assert_eq!(send(&mut call), RV_ERR_MISUSE, "a step needs its arguments"); + assert!( + call.tool_call.is_null(), + "a step that ends the call leaves no state, whatever its status" + ); + + assert_eq!(step(&mut call, "never", "{}"), RV_PENDING); + let short = step_args(size_of_u32::() - 1, "never", "{}"); + call.args = (&raw const short).cast(); + assert_eq!(send(&mut call), RV_ERR_UNSUPPORTED, "short arguments"); + assert!(call.tool_call.is_null()); + assert_eq!(finished(), []); + assert_eq!(delete_pack(&mut call), RV_OK); + assert_eq!(unload(&mut call), RV_OK); +} diff --git a/crates/harness-internal/test-plugin/src/lib.rs b/crates/harness-internal/test-plugin/src/lib.rs index 32326f438..ab7387694 100644 --- a/crates/harness-internal/test-plugin/src/lib.rs +++ b/crates/harness-internal/test-plugin/src/lib.rs @@ -1,28 +1,36 @@ //! `harness-test-plugin` - a library Plugin for the loader's end-to-end //! tests: a dynamic library that exports `runvil_plugin` and follows the C -//! contract. +//! contract. It inserts one Pack, `fixture/test`. Three of its tools show +//! a call that ends in one step, one that takes three, and one that never +//! ends, and three more call the step's file functions: `stat`, `list`, +//! and `read`. //! //! ## Invariants //! //! - No `[dependencies]` at all, so a library Plugin needs nothing from -//! Runvil. Only the end-to-end tests in `tests/it` name other crates, -//! `harness-plugin-loader`, `runvil-plugin`, `libloading`, and `libc`, as +//! Runvil. Only the end-to-end tests in `tests/it` name other crates, as //! `[dev-dependencies]`. The C types below are a private copy of the //! contract's, not an import. -//! - Only `runvil_plugin` and the three counters -//! `harness_test_plugin_loads`, `harness_test_plugin_unloads`, and -//! `harness_test_plugin_live` are public. The mirrored C types stay -//! private, and unit tests pin them to the same layout numbers as -//! `runvil-plugin-sys`. +//! - Only `runvil_plugin` and the five counters `harness_test_plugin_loads`, +//! `harness_test_plugin_unloads`, `harness_test_plugin_live`, +//! `harness_test_plugin_packs`, and `harness_test_plugin_calls` are +//! public. The mirrored C types stay private, and unit tests pin them to +//! the same layout numbers as `runvil-plugin-sys`. //! - The counters are process-global, so each copy of the library that is //! opened counts on its own, from zero. //! - The entry point never unwinds: it has no `unwrap`, `expect`, or //! indexing that can panic. //! - `RV_PLUGIN_LOAD` loads only when the `RvHost` table reports exactly -//! version 0.2, and checks that before it reads `args`. +//! version 0.4, and checks that before it reads `args`. //! - `RV_PLUGIN_LOAD` describes the Plugin exactly once, as `test-plugin` -//! at `0.1.0`, and sets the instance pointer only when that succeeds. A -//! failing load frees what it made. +//! at `0.1.0`, inserts its one Pack, and sets the instance pointer only +//! when both succeed. A failing load frees what it made, and so does a +//! failing `RV_PACK_NEW`. +//! - A tool call's state is freed by the step that ends the call or by +//! `RV_TOOL_CANCEL`, never by both. +//! - A file tool copies what a file function hands back before it calls +//! anything else, and a file function that fails ends the call with a +//! text that says so, not as a failed call. #![expect(unsafe_code, reason = "exports a C entry point")] use std::ffi::{c_char, c_void}; @@ -46,23 +54,75 @@ const RV_ERR_MISUSE: RvStatus = 4; const RV_ERR_REFUSED: RvStatus = 5; /// The Plugin does not know the verb it was sent. const RV_ERR_UNSUPPORTED: RvStatus = 6; +/// The tool call needs another step. +const RV_PENDING: RvStatus = 7; +/// A file function failed, and its `error` says how. +const RV_ERR_FILE: RvStatus = 8; /// The operation an `RvCall` asks for. type RvVerb = u32; -/// Make the Plugin instance and name the Plugin. +/// Make the Plugin instance, name the Plugin, and insert its Packs. const RV_PLUGIN_LOAD: RvVerb = 1; /// Free the Plugin instance. const RV_PLUGIN_UNLOAD: RvVerb = 2; +/// Make one run's instance of a Pack and list its tools. +const RV_PACK_NEW: RvVerb = 3; +/// Free one run's instance of a Pack. +const RV_PACK_DELETE: RvVerb = 4; +/// Run one step of a tool call. +const RV_TOOL_CALL: RvVerb = 5; +/// Free the state of a tool call the Host has abandoned. +const RV_TOOL_CANCEL: RvVerb = 6; + +/// How a tool call ended. +type RvResultKind = u32; +/// The call succeeded, and the Plugin vouches for its text. +const RV_RESULT_TRUSTED: RvResultKind = 1; +/// The call failed for a reason the other kinds do not name. +const RV_RESULT_OTHER: RvResultKind = 5; + +/// What a path names, in an `RvStat`. +type RvFileType = u32; +/// A regular file. +const RV_FILE_TYPE_FILE: RvFileType = 1; +/// A directory. +const RV_FILE_TYPE_DIRECTORY: RvFileType = 2; +/// A symbolic link. +const RV_FILE_TYPE_SYMLINK: RvFileType = 3; + +/// Why a file function failed, in an `RvFileError`. +type RvFileErrorKind = u32; /// The contract's major version, which the `RvHost` table must report. const RV_API_MAJOR: u32 = 0; /// The contract's minor version, which the `RvHost` table must report. -const RV_API_MINOR: u32 = 2; +const RV_API_MINOR: u32 = 4; /// The name this Plugin gives `describe_plugin`. const NAME: &str = "test-plugin"; /// The version this Plugin gives `describe_plugin`. const VERSION: &str = "0.1.0"; +/// The name of the one Pack this Plugin inserts. +const PACK: &str = "fixture/test"; +/// What the Pack says it offers. +const PACK_DESCRIPTION: &str = "The loader's test fixture"; +/// The parameters of a tool that takes none: an object with no properties. +const NO_PARAMETERS: &str = r#"{"type":"object","properties":{}}"#; +/// The parameters of a file tool: an object with the one string `path`. +const PATH_PARAMETERS: &str = + r#"{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}"#; +/// The tool that ends in one step, with `hello ` and its arguments. +const HELLO: &str = "hello"; +/// The tool that ends in its third step, with `3 steps`. +const STEPS: &str = "steps"; +/// The tool that never ends. +const NEVER: &str = "never"; +/// The tool that says what its `path` names. +const STAT: &str = "stat"; +/// The tool that lists the directory at its `path`. +const LIST: &str = "list"; +/// The tool that answers the text of the file at its `path`. +const READ: &str = "read"; /// A UTF-8 string that is not NUL-terminated. #[repr(C)] @@ -74,6 +134,61 @@ struct RvStr { len: usize, } +/// A run of bytes, which `file_read` hands back. +#[repr(C)] +#[derive(Clone, Copy, Debug)] +struct RvBytes { + /// The first byte. + ptr: *const u8, + /// The length in bytes. + len: usize, +} + +/// What a path names, which `file_stat` fills and each `RvDirEntry` holds. +#[repr(C)] +#[derive(Clone, Copy, Debug)] +struct RvStat { + /// One of the `RV_FILE_TYPE_` constants. + file_type: RvFileType, + /// Zero. + reserved: u32, + /// The length of a file in bytes. + len: u64, +} + +/// One entry of a directory. +#[repr(C)] +#[derive(Clone, Copy, Debug)] +struct RvDirEntry { + /// The entry's name within its directory. + name: RvStr, + /// What the entry is. + stat: RvStat, +} + +/// The entries of a directory, which `file_list` hands back. +#[repr(C)] +#[derive(Clone, Copy, Debug)] +struct RvDirEntries { + /// The first entry. + ptr: *const RvDirEntry, + /// How many entries there are. + len: usize, +} + +/// Why a file function failed, which the function fills when it returns +/// `RV_ERR_FILE`. +#[repr(C)] +#[derive(Clone, Copy, Debug)] +struct RvFileError { + /// One of the contract's `RV_FILE_ERROR_` constants. + kind: RvFileErrorKind, + /// Zero. + reserved: u32, + /// What went wrong. + text: RvStr, +} + /// What a Plugin tells `describe_plugin` about itself. #[repr(C)] #[derive(Debug)] @@ -88,6 +203,50 @@ struct RvPluginInfo { version: RvStr, } +/// What a Plugin tells `insert_pack` about one of its Packs. +#[repr(C)] +#[derive(Debug)] +struct RvPackInfo { + /// `size_of::()`. + size: u32, + /// Zero. + reserved: u32, + /// The Pack's name, a vendor and a Pack id joined by `/`. + name: RvStr, + /// One line saying what the Pack offers. + description: RvStr, +} + +/// What a Pack instance tells `add_tool` about one of its tools. +#[repr(C)] +#[derive(Debug)] +struct RvToolInfo { + /// `size_of::()`. + size: u32, + /// Zero. + reserved: u32, + /// The tool's name within its Pack. + name: RvStr, + /// The name a model calls the tool by, or empty. + wire_name: RvStr, + /// What the tool does. + description: RvStr, + /// The JSON text of the tool's parameter schema. + parameters: RvStr, +} + +/// How a tool call ended, which a step tells `finish`. +#[repr(C)] +#[derive(Debug)] +struct RvToolResult { + /// `size_of::()`. + size: u32, + /// One of the `RV_RESULT_` constants. + kind: RvResultKind, + /// The call's output, or a message saying what went wrong. + text: RvStr, +} + /// What the `RvHost` table reports on every call: the contract version. #[repr(C)] #[derive(Debug)] @@ -118,6 +277,69 @@ struct RvLoadArgs { refuse: unsafe extern "C" fn(ctx: *mut c_void, reason: RvStr) -> RvStatus, /// Names the Plugin, exactly once. describe_plugin: unsafe extern "C" fn(ctx: *mut c_void, info: *const RvPluginInfo) -> RvStatus, + /// Inserts one Pack. + insert_pack: unsafe extern "C" fn(ctx: *mut c_void, info: *const RvPackInfo) -> RvStatus, +} + +/// The arguments of `RV_PACK_NEW`: the Pack to make an instance of, and +/// the functions the Plugin may call while it makes one. +#[repr(C)] +#[derive(Debug)] +struct RvPackNewArgs { + /// `size_of::()`. + size: u32, + /// Zero. + reserved: u32, + /// The loader's context for this call. + ctx: *mut c_void, + /// The name of the Pack, as the Plugin gave it to `insert_pack`. + pack: RvStr, + /// Declines to make the instance. This Plugin never calls it. + refuse: unsafe extern "C" fn(ctx: *mut c_void, reason: RvStr) -> RvStatus, + /// Adds one tool to the instance. + add_tool: unsafe extern "C" fn(ctx: *mut c_void, info: *const RvToolInfo) -> RvStatus, +} + +/// The arguments of `RV_TOOL_CALL`: the tool, its arguments, the function +/// that ends the call, and the file functions. +#[repr(C)] +#[derive(Debug)] +struct RvToolCallArgs { + /// `size_of::()`. + size: u32, + /// Zero. + reserved: u32, + /// The loader's context for this step. + ctx: *mut c_void, + /// The tool's name, as the instance gave it to `add_tool`. + tool: RvStr, + /// The JSON text of the call's arguments. + arguments: RvStr, + /// Ends the call with its result. + finish: unsafe extern "C" fn(ctx: *mut c_void, result: *const RvToolResult) -> RvStatus, + /// Says what `path` names. + file_stat: unsafe extern "C" fn( + ctx: *mut c_void, + path: RvStr, + stat: *mut RvStat, + error: *mut RvFileError, + ) -> RvStatus, + /// Lists the directory at `path`. + file_list: unsafe extern "C" fn( + ctx: *mut c_void, + path: RvStr, + entries: *mut RvDirEntries, + error: *mut RvFileError, + ) -> RvStatus, + /// Reads up to `len` bytes of the file at `path` from `offset` on. + file_read: unsafe extern "C" fn( + ctx: *mut c_void, + path: RvStr, + offset: u64, + len: u64, + bytes: *mut RvBytes, + error: *mut RvFileError, + ) -> RvStatus, } /// One call into the Plugin's entry point. @@ -130,14 +352,16 @@ struct RvCall { verb: RvVerb, /// What the `RvHost` table reports on every call. host: *const RvHost, - /// The Plugin instance: NULL going into `RV_PLUGIN_LOAD`, which may - /// set it. + /// The Plugin instance: NULL going into `RV_PLUGIN_LOAD`, which sets + /// it. plugin: *mut c_void, - /// The per-run Pack instance; NULL until the Pack verbs exist. + /// One run's instance of a Pack: NULL going into `RV_PACK_NEW`, which + /// sets it. pack_run: *mut c_void, - /// The verb's own arguments: an `RvLoadArgs` for `RV_PLUGIN_LOAD`, and - /// NULL for `RV_PLUGIN_UNLOAD`. + /// The verb's own arguments, or NULL for a verb that has none. args: *const c_void, + /// The state of one tool call: NULL going into its first step. + tool_call: *mut c_void, } /// What `RV_PLUGIN_LOAD` makes and `RV_PLUGIN_UNLOAD` frees. It is not @@ -148,6 +372,18 @@ struct Instance { _ballast: [u8; 16], } +/// What `RV_PACK_NEW` makes and `RV_PACK_DELETE` frees. +struct PackRun { + /// Ballast that gives the instance a size. + _ballast: [u8; 16], +} + +/// What a tool call that takes more than one step keeps between them. +struct CallState { + /// How many steps the call has run. + steps: u32, +} + #[cfg(test)] thread_local! { /// How many `Instance` values this thread has made and not yet @@ -164,6 +400,10 @@ static LOADS: AtomicU32 = AtomicU32::new(0); static UNLOADS: AtomicU32 = AtomicU32::new(0); /// `Instance` values made and not yet dropped, across every thread. static LIVE: AtomicU32 = AtomicU32::new(0); +/// `PackRun` values made and not yet dropped, across every thread. +static PACKS: AtomicU32 = AtomicU32::new(0); +/// `CallState` values made and not yet dropped, across every thread. +static CALLS: AtomicU32 = AtomicU32::new(0); impl Instance { /// Makes one instance and counts it as live. @@ -183,6 +423,34 @@ impl Drop for Instance { } } +impl PackRun { + /// Makes one Pack instance and counts it as live. + fn new() -> PackRun { + PACKS.fetch_add(1, Ordering::Relaxed); + PackRun { _ballast: [0; 16] } + } +} + +impl Drop for PackRun { + fn drop(&mut self) { + PACKS.fetch_sub(1, Ordering::Relaxed); + } +} + +impl CallState { + /// Makes the state of one call and counts it as live. + fn new() -> CallState { + CALLS.fetch_add(1, Ordering::Relaxed); + CallState { steps: 0 } + } +} + +impl Drop for CallState { + fn drop(&mut self) { + CALLS.fetch_sub(1, Ordering::Relaxed); + } +} + /// Successful `RV_PLUGIN_LOAD` calls since this copy of the library was /// opened. #[unsafe(no_mangle)] @@ -204,6 +472,20 @@ pub extern "C" fn harness_test_plugin_live() -> u32 { LIVE.load(Ordering::Relaxed) } +/// Pack instances made by `RV_PACK_NEW` and not yet freed by +/// `RV_PACK_DELETE`. +#[unsafe(no_mangle)] +pub extern "C" fn harness_test_plugin_packs() -> u32 { + PACKS.load(Ordering::Relaxed) +} + +/// Tool calls that hold state between steps: made by a call's first step, +/// freed by its last step or by `RV_TOOL_CANCEL`. +#[unsafe(no_mangle)] +pub extern "C" fn harness_test_plugin_calls() -> u32 { + CALLS.load(Ordering::Relaxed) +} + /// A type's size as the contract's `u32`. The contract's structs are a few /// dozen bytes, so the fallback is unreachable; it is `u32::MAX` so that a /// wrong size fails a check instead of panicking. @@ -211,13 +493,58 @@ fn size_of_u32() -> u32 { u32::try_from(size_of::()).unwrap_or(u32::MAX) } -/// The fixture's Plugin entry point, speaking contract 0.2. +/// `text` as the contract's pointer and length. +fn rv_str(text: &str) -> RvStr { + RvStr { + ptr: text.as_ptr().cast(), + len: text.len(), + } +} + +/// The text `s` holds, or the empty string when it is not UTF-8. +/// +/// # Safety +/// When `s.len` is nonzero, `s.ptr` must be valid for reads of `s.len` +/// bytes for the lifetime `'a`. +unsafe fn text<'a>(s: RvStr) -> &'a str { + if s.len == 0 || s.ptr.is_null() { + return ""; + } + // SAFETY: `s.ptr` is not NULL, and the caller guarantees it is valid + // for `s.len` bytes. + let bytes = unsafe { std::slice::from_raw_parts(s.ptr.cast::(), s.len) }; + std::str::from_utf8(bytes).unwrap_or_default() +} + +/// The arguments struct of the verb `call` names: `RV_ERR_MISUSE` when the +/// call has none, and `RV_ERR_UNSUPPORTED` when it is shorter than `T`. +/// +/// # Safety +/// `call` must point to a valid `RvCall` whose `args` is NULL or points to +/// one of the contract's arguments structs, each of which begins with its +/// `size`, as long as that `size` says, for the lifetime `'a`. +unsafe fn args<'a, T>(call: *mut RvCall) -> Result<&'a T, RvStatus> { + // SAFETY: the caller guarantees `call` is valid. + let args = unsafe { (*call).args }.cast::(); + if args.is_null() { + return Err(RV_ERR_MISUSE); + } + // SAFETY: a non-NULL `args` begins with its `size`, which is readable + // on its own. + if unsafe { args.cast::().read() } < size_of_u32::() { + return Err(RV_ERR_UNSUPPORTED); + } + // SAFETY: `size` says the loader wrote a whole `T`. + Ok(unsafe { &*args }) +} + +/// The fixture's Plugin entry point, speaking contract 0.4. /// /// # Safety -/// `call` must point to a valid `RvCall` whose `host` points to a valid -/// `RvHost`, and whose `args`, for `RV_PLUGIN_LOAD`, is NULL or points to -/// an `RvLoadArgs` at least as long as its `size` says, as the C contract -/// requires. +/// `call` must point to a valid `RvCall` made as the C contract requires: +/// its `host` points to a valid `RvHost`, its `args` is NULL or points to +/// the arguments struct of its verb, at least as long as its `size` says, +/// and its Plugin pointers are the ones this entry point set. #[unsafe(no_mangle)] #[expect( private_interfaces, @@ -226,29 +553,32 @@ fn size_of_u32() -> u32 { pub unsafe extern "C" fn runvil_plugin(call: *mut RvCall) -> RvStatus { // SAFETY: the caller guarantees `call` points to a valid `RvCall`. let verb = unsafe { (*call).verb }; - match verb { - RV_PLUGIN_LOAD => { - // SAFETY: as above, and the caller guarantees the `RvHost`. - unsafe { load(call) } + // SAFETY: in each arm, as above, and the caller vouches for the + // pointers the verb reads. + unsafe { + match verb { + RV_PLUGIN_LOAD => load(call), + RV_PLUGIN_UNLOAD => unload(call), + RV_PACK_NEW => pack_new(call), + RV_PACK_DELETE => pack_delete(call), + RV_TOOL_CALL => tool_call(call), + RV_TOOL_CANCEL => tool_cancel(call), + _ => RV_ERR_UNSUPPORTED, } - RV_PLUGIN_UNLOAD => { - // SAFETY: as above. - unsafe { unload(call) } - } - _ => RV_ERR_UNSUPPORTED, } } /// Handles `RV_PLUGIN_LOAD`: checks the `RvHost` version, then the load -/// arguments, makes the instance, describes the Plugin once through its -/// `ctx`, and only then hands the instance to the loader. +/// arguments, makes the instance, describes the Plugin once and inserts +/// its Pack through the load's `ctx`, and only then hands the instance to +/// the loader. /// /// # Safety /// `call` points to a valid `RvCall` whose `host` and `args` are as the /// entry point's own safety contract requires. unsafe fn load(call: *mut RvCall) -> RvStatus { // SAFETY: the caller guarantees `call` is valid. - let (host, args) = unsafe { ((*call).host, (*call).args.cast::()) }; + let host = unsafe { (*call).host }; // The version check comes first and reads only `size` until the table // is known to be long enough, so a short table is never read past its // end. It also runs before `args` is read and before anything is @@ -262,29 +592,18 @@ unsafe fn load(call: *mut RvCall) -> RvStatus { if version != (RV_API_MAJOR, RV_API_MINOR) { return RV_ERR_UNSUPPORTED; } - if args.is_null() { - return RV_ERR_MISUSE; - } - // SAFETY: the caller guarantees a non-NULL `args` points to an - // `RvLoadArgs`, whose first field, `size`, is readable on its own. - if unsafe { (*args).size } < size_of_u32::() { - return RV_ERR_UNSUPPORTED; - } - // SAFETY: `size` says the loader wrote a whole `RvLoadArgs`. - let args = unsafe { &*args }; + // SAFETY: for this verb `args` is NULL or an `RvLoadArgs`. + let args = match unsafe { args::(call) } { + Ok(args) => args, + Err(status) => return status, + }; let instance = Box::new(Instance::new()); let info = RvPluginInfo { size: size_of_u32::(), reserved: 0, - name: RvStr { - ptr: NAME.as_ptr().cast(), - len: NAME.len(), - }, - version: RvStr { - ptr: VERSION.as_ptr().cast(), - len: VERSION.len(), - }, + name: rv_str(NAME), + version: rv_str(VERSION), }; // SAFETY: `describe_plugin` and `ctx` are the load's own arguments, // and `info` and its strings outlive the call. @@ -293,6 +612,17 @@ unsafe fn load(call: *mut RvCall) -> RvStatus { // `instance` drops here, so a failing load frees what it made. return status; } + let pack = RvPackInfo { + size: size_of_u32::(), + reserved: 0, + name: rv_str(PACK), + description: rv_str(PACK_DESCRIPTION), + }; + // SAFETY: as above, for `insert_pack`. + let status = unsafe { (args.insert_pack)(args.ctx, &raw const pack) }; + if status != RV_OK { + return status; + } // SAFETY: the caller guarantees `call` is valid, and nothing else // reads it meanwhile. unsafe { (*call).plugin = Box::into_raw(instance).cast() }; @@ -318,3 +648,256 @@ unsafe fn unload(call: *mut RvCall) -> RvStatus { UNLOADS.fetch_add(1, Ordering::Relaxed); RV_OK } + +/// Handles `RV_PACK_NEW`: makes the Pack instance, adds its six tools +/// through the call's `ctx`, and only then hands the instance to the +/// loader. +/// +/// # Safety +/// `call` points to a valid `RvCall` whose `args` is NULL or an +/// `RvPackNewArgs` as long as its `size` says. +unsafe fn pack_new(call: *mut RvCall) -> RvStatus { + // SAFETY: for this verb `args` is NULL or an `RvPackNewArgs`. + let args = match unsafe { args::(call) } { + Ok(args) => args, + Err(status) => return status, + }; + // SAFETY: the loader keeps the Pack's name valid for the call. + if unsafe { text(args.pack) } != PACK { + return RV_ERR_MISUSE; + } + let pack_run = Box::new(PackRun::new()); + for (name, description, parameters) in [ + ( + HELLO, + "Answers hello and its arguments in one step.", + NO_PARAMETERS, + ), + (STEPS, "Answers in its third step.", NO_PARAMETERS), + (NEVER, "Never answers.", NO_PARAMETERS), + (STAT, "Says what a path names.", PATH_PARAMETERS), + (LIST, "Lists a directory.", PATH_PARAMETERS), + (READ, "Answers the text of a file.", PATH_PARAMETERS), + ] { + let info = RvToolInfo { + size: size_of_u32::(), + reserved: 0, + name: rv_str(name), + wire_name: rv_str(""), + description: rv_str(description), + parameters: rv_str(parameters), + }; + // SAFETY: `add_tool` and `ctx` are the call's own arguments, and + // `info` and its strings outlive the call. + let status = unsafe { (args.add_tool)(args.ctx, &raw const info) }; + if status != RV_OK { + // `pack_run` drops here, so a failing call frees what it made. + return status; + } + } + // SAFETY: the caller guarantees `call` is valid, and nothing else + // reads it meanwhile. + unsafe { (*call).pack_run = Box::into_raw(pack_run).cast() }; + RV_OK +} + +/// Handles `RV_PACK_DELETE`: frees the instance `RV_PACK_NEW` set. +/// +/// # Safety +/// `call` points to a valid `RvCall` whose `pack_run` is NULL or the +/// pointer this entry point's `RV_PACK_NEW` set, not yet deleted. +unsafe fn pack_delete(call: *mut RvCall) -> RvStatus { + // SAFETY: the caller guarantees `call` is valid. + let pack_run = unsafe { (*call).pack_run }; + if pack_run.is_null() { + return RV_ERR_MISUSE; + } + // SAFETY: a non-NULL `pack_run` is the `Box` that + // `RV_PACK_NEW` leaked, and the loader deletes each instance once. + drop(unsafe { Box::from_raw(pack_run.cast::()) }); + RV_OK +} + +/// Handles `RV_TOOL_CALL`: runs one step of the tool the arguments name. +/// The state of a call is taken out of `call->tool_call` first, so every +/// return other than `RV_PENDING` leaves none behind. +/// +/// # Safety +/// `call` points to a valid `RvCall` whose `tool_call` is NULL or the +/// state the call's last step left, and whose `args` is NULL or an +/// `RvToolCallArgs` as long as its `size` says. +unsafe fn tool_call(call: *mut RvCall) -> RvStatus { + // SAFETY: the caller guarantees `call` is valid, and a non-NULL + // `tool_call` is the `Box` the last step leaked. + let state = unsafe { + let state = (*call).tool_call.cast::(); + (*call).tool_call = std::ptr::null_mut(); + (!state.is_null()).then(|| Box::from_raw(state)) + }; + // SAFETY: for this verb `args` is NULL or an `RvToolCallArgs`. + let args = match unsafe { args::(call) } { + Ok(args) => args, + Err(status) => return status, + }; + // SAFETY: the loader keeps both strings valid for the step. + let (tool, arguments) = unsafe { (text(args.tool), text(args.arguments)) }; + let mut state = state.unwrap_or_else(|| Box::new(CallState::new())); + state.steps += 1; + let (kind, answer) = match tool { + HELLO => (RV_RESULT_TRUSTED, format!("hello {arguments}")), + STEPS if state.steps == 3 => (RV_RESULT_TRUSTED, "3 steps".to_owned()), + STEPS | NEVER => { + // SAFETY: as above. + unsafe { (*call).tool_call = Box::into_raw(state).cast() }; + return RV_PENDING; + } + // SAFETY: `args` is the arguments of this step, which is open on + // this thread. + STAT | LIST | READ => (RV_RESULT_TRUSTED, unsafe { + file_answer(args, tool, path_of(arguments)) + }), + other => ( + RV_RESULT_OTHER, + format!("test-plugin has no tool named {other}"), + ), + }; + // The call ends here, so its state is freed before the step returns. + drop(state); + let result = RvToolResult { + size: size_of_u32::(), + kind, + text: rv_str(&answer), + }; + // SAFETY: `finish` and `ctx` are the step's own arguments, and + // `result` and its text outlive the call. + unsafe { (args.finish)(args.ctx, &raw const result) } +} + +/// The value of the string `path` in `arguments`, the JSON text of an +/// object, or the empty string when it has none. It reads up to the next +/// `"`, so it serves only a path with no `"` and no `\` in it, which is +/// all the tests send. +fn path_of(arguments: &str) -> &str { + arguments + .split_once(r#""path":""#) + .and_then(|(_, rest)| rest.split_once('"')) + .map_or("", |(path, _)| path) +} + +/// What the file tool `tool` answers for `path`: what the step's file +/// function handed back, as text, or `failed (kind): text` when the +/// function returned `RV_ERR_FILE`. +/// +/// # Safety +/// `args` must be the arguments of the step open on this thread. +unsafe fn file_answer(args: &RvToolCallArgs, tool: &str, path: &str) -> String { + let path = rv_str(path); + let mut error = RvFileError { + kind: 0, + reserved: 0, + text: rv_str(""), + }; + // SAFETY: in each arm, the file function and `ctx` are the step's + // own, `path` outlives the call, and the result and `error` are live + // structs of this frame. What a function hands back is copied before + // any other file function is called. + let (status, answer) = unsafe { + match tool { + STAT => { + let mut stat = RvStat { + file_type: 0, + reserved: 0, + len: 0, + }; + let status = (args.file_stat)(args.ctx, path, &raw mut stat, &raw mut error); + (status, stat_text(stat)) + } + LIST => { + let mut entries = RvDirEntries { + ptr: std::ptr::null(), + len: 0, + }; + let status = (args.file_list)(args.ctx, path, &raw mut entries, &raw mut error); + (status, list_text(lent(entries.ptr, entries.len))) + } + _ => { + let mut bytes = RvBytes { + ptr: std::ptr::null(), + len: 0, + }; + let status = + (args.file_read)(args.ctx, path, 0, u64::MAX, &raw mut bytes, &raw mut error); + let read = String::from_utf8_lossy(lent(bytes.ptr, bytes.len)).into_owned(); + (status, read) + } + } + }; + match status { + RV_OK => answer, + // SAFETY: the loader filled `error`, and its text is valid until + // the step's next file function. + RV_ERR_FILE => format!("failed ({}): {}", error.kind, unsafe { text(error.text) }), + other => format!("the file function returned status {other}"), + } +} + +/// `stat` as the `stat` tool words it. +fn stat_text(stat: RvStat) -> String { + match stat.file_type { + RV_FILE_TYPE_FILE => format!("file {}", stat.len), + RV_FILE_TYPE_DIRECTORY => "directory".to_owned(), + RV_FILE_TYPE_SYMLINK => "symlink".to_owned(), + _ => "other".to_owned(), + } +} + +/// `entries` as the `list` tool words them: the names joined by `,`, each +/// directory with a `/` after its name. +/// +/// # Safety +/// The name of each entry must be valid for reads of its length. +unsafe fn list_text(entries: &[RvDirEntry]) -> String { + let mut listed = String::new(); + for entry in entries { + if !listed.is_empty() { + listed.push(','); + } + // SAFETY: the caller guarantees the name. + listed.push_str(unsafe { text(entry.name) }); + if entry.stat.file_type == RV_FILE_TYPE_DIRECTORY { + listed.push('/'); + } + } + listed +} + +/// The `len` values a file function lent at `ptr`, or none when `ptr` is +/// NULL, as it is in a result no file function filled. +/// +/// # Safety +/// When `ptr` is not NULL and `len` is nonzero, `ptr` must be valid for +/// reads of `len` values for the lifetime `'a`. +unsafe fn lent<'a, T>(ptr: *const T, len: usize) -> &'a [T] { + if ptr.is_null() || len == 0 { + return &[]; + } + // SAFETY: `ptr` is not NULL, and the caller guarantees it is valid + // for `len` values. + unsafe { std::slice::from_raw_parts(ptr, len) } +} + +/// Handles `RV_TOOL_CANCEL`: frees the state of an abandoned call. +/// +/// # Safety +/// `call` points to a valid `RvCall` whose `tool_call` is NULL or the +/// state the call's last step left. +unsafe fn tool_cancel(call: *mut RvCall) -> RvStatus { + // SAFETY: the caller guarantees `call` is valid. + let state = unsafe { (*call).tool_call }.cast::(); + if !state.is_null() { + // SAFETY: a non-NULL `tool_call` is the `Box` the last + // step leaked, and the loader cancels a call once. + drop(unsafe { Box::from_raw(state) }); + } + RV_OK +} diff --git a/crates/harness-internal/test-plugin/tests/it/insert.rs b/crates/harness-internal/test-plugin/tests/it/insert.rs index 161023cd9..b1a395963 100644 --- a/crates/harness-internal/test-plugin/tests/it/insert.rs +++ b/crates/harness-internal/test-plugin/tests/it/insert.rs @@ -152,6 +152,37 @@ fn a_second_copy_with_the_same_plugin_name_fails_as_a_duplicate() { remove(&dir); } +#[test] +fn a_pack_the_registry_already_holds_is_reported_and_its_plugin_still_loads() { + let first_dir = scratch("a_pack_the_registry_already_holds_first"); + let second_dir = scratch("a_pack_the_registry_already_holds_second"); + copy_fixture(&first_dir, "plugin"); + let second_file = copy_fixture(&second_dir, "plugin"); + let mut packs = PackRegistry::new(); + let first = insert_plugin_dir(&mut packs, &first_dir).expect("the directory reads"); + assert!(first.failures().is_empty(), "{:?}", first.failures()); + + let second = insert_plugin_dir(&mut packs, &second_dir).expect("the directory reads"); + assert_eq!(second.plugins().len(), 1, "the second Plugin did load"); + let [failure] = second.failures() else { + panic!("its Pack is refused, got {:?}", second.failures()); + }; + assert_eq!(fact(failure, ErrorFact::Reason), Some("pack")); + assert_eq!(fact(failure, ErrorFact::Name), Some("fixture/test")); + assert_eq!( + fact(failure, ErrorFact::Path), + Some(library_path(&second_file).as_str()) + ); + assert_eq!( + failure.message, + "the Pack fixture/test of the Plugin test-plugin was not inserted: Pack `fixture/test` \ + is already in the registry" + ); + drop((first, second, packs)); + remove(&first_dir); + remove(&second_dir); +} + #[test] fn a_missing_directory_returns_an_io_error() { let dir = scratch("a_missing_directory_returns_an_io_error"); diff --git a/crates/harness-internal/test-plugin/tests/it/lifecycle.rs b/crates/harness-internal/test-plugin/tests/it/lifecycle.rs index 6620be777..c000ab4b0 100644 --- a/crates/harness-internal/test-plugin/tests/it/lifecycle.rs +++ b/crates/harness-internal/test-plugin/tests/it/lifecycle.rs @@ -4,15 +4,13 @@ //! library the loader opened; the operating system's already-loaded query //! shows the last. -use std::path::Path; - use harness_plugin_loader::insert_plugin_dir; use libloading::Library; -use runvil_plugin::PackRegistry; +use runvil_plugin::{PackOrigin, PackRegistry}; -use crate::support::{copy_fixture, remove, scratch}; +use crate::support::{already_loaded, copy_fixture, counter, remove, scratch}; -/// What the fixture's three counters read. +/// What the fixture's three Plugin counters read. #[derive(Debug, PartialEq, Eq)] struct Counters { /// Successful `RV_PLUGIN_LOAD` calls. @@ -23,41 +21,12 @@ struct Counters { live: u32, } -/// Opens the library at the absolute `path` only when the process already -/// has it loaded. The handle adds a reference, so the library stays mapped -/// until the handle drops. -#[cfg(windows)] -fn already_loaded(path: &Path) -> Result { - libloading::os::windows::Library::open_already_loaded(path).map(Library::from) -} - -/// Opens the library at the absolute `path` only when the process already -/// has it loaded. The handle adds a reference, so the library stays mapped -/// until the handle drops. -#[cfg(unix)] -fn already_loaded(path: &Path) -> Result { - // SAFETY: with `RTLD_NOLOAD` the call loads nothing, so no library - // initializer runs; it only adds a reference to one already loaded. - let library = unsafe { - libloading::os::unix::Library::open(Some(path), libc::RTLD_NOLOAD | libc::RTLD_LAZY) - }; - library.map(Library::from) -} - -/// Calls the counter the fixture exports as `name` through `library`. -fn counter(library: &Library, name: &str) -> u32 { - // SAFETY: the fixture exports each counter as `extern "C" fn() -> u32`. - let read = unsafe { library.get:: u32>(name.as_bytes()) } - .unwrap_or_else(|error| panic!("the fixture exports {name}: {error}")); - read() -} - /// Reads all three counters through `library`. fn counters(library: &Library) -> Counters { Counters { - loads: counter(library, "harness_test_plugin_loads"), - unloads: counter(library, "harness_test_plugin_unloads"), - live: counter(library, "harness_test_plugin_live"), + loads: counter(library, "loads"), + unloads: counter(library, "unloads"), + live: counter(library, "live"), } } @@ -66,7 +35,8 @@ fn the_fixture_is_loaded_invoked_unloaded_once_and_closed() { let dir = scratch("the_fixture_is_loaded_invoked_unloaded_once_and_closed"); let file = std::path::absolute(copy_fixture(&dir, "plugin")).expect("the working directory is known"); - let report = insert_plugin_dir(&mut PackRegistry::new(), &dir).expect("the directory reads"); + let mut packs = PackRegistry::new(); + let report = insert_plugin_dir(&mut packs, &dir).expect("the directory reads"); assert!(report.failures().is_empty(), "{:?}", report.failures()); let [plugin] = report.plugins() else { panic!("one Plugin loads, got {:?}", report.plugins()); @@ -76,6 +46,20 @@ fn the_fixture_is_loaded_invoked_unloaded_once_and_closed() { "test-plugin 0.1.0", "the load described the Plugin, so its entry point was invoked" ); + let inserted: Vec<(&str, &str, PackOrigin)> = packs + .list() + .filter(|(_, origin)| *origin != PackOrigin::Builtin) + .map(|(info, origin)| (info.fqn, info.description, origin)) + .collect(); + assert_eq!( + inserted, + [( + "fixture/test", + "The loader's test fixture", + PackOrigin::Plugin + )], + "the load inserted the fixture's one Pack" + ); let library = already_loaded(&file).expect("the loader holds the library open"); assert_eq!( @@ -88,6 +72,16 @@ fn the_fixture_is_loaded_invoked_unloaded_once_and_closed() { "one load made one instance, and nothing unloaded it yet" ); drop(report); + assert_eq!( + counters(&library), + Counters { + loads: 1, + unloads: 0, + live: 1 + }, + "the Pack in the registry still holds the Plugin" + ); + drop(packs); assert_eq!( counters(&library), Counters { @@ -95,7 +89,7 @@ fn the_fixture_is_loaded_invoked_unloaded_once_and_closed() { unloads: 1, live: 0 }, - "dropping the report sent exactly one unload, which freed the instance" + "dropping the last holder sent exactly one unload, which freed the instance" ); drop(library); assert!( diff --git a/crates/harness-internal/test-plugin/tests/it/main.rs b/crates/harness-internal/test-plugin/tests/it/main.rs index 94df7a193..1eb697f0b 100644 --- a/crates/harness-internal/test-plugin/tests/it/main.rs +++ b/crates/harness-internal/test-plugin/tests/it/main.rs @@ -1,6 +1,7 @@ //! End-to-end tests for the loader against the fixture's real dynamic -//! library: `insert_plugin_dir` opens it, loads its Plugin, and closes it -//! again. +//! library: `insert_plugin_dir` opens it, loads its Plugin, inserts its +//! Pack, and closes it again, and a prompt calls its tools through the +//! Harness. #![expect( clippy::expect_used, reason = "test helpers panic on setup failure, which is the desired behavior" @@ -12,4 +13,5 @@ mod insert; mod lifecycle; +mod run; mod support; diff --git a/crates/harness-internal/test-plugin/tests/it/run.rs b/crates/harness-internal/test-plugin/tests/it/run.rs new file mode 100644 index 000000000..aaef8cc47 --- /dev/null +++ b/crates/harness-internal/test-plugin/tests/it/run.rs @@ -0,0 +1,319 @@ +//! A prompt calls the fixture library's tools through the real Harness: +//! the Pack the library inserted is declared, each run gets its own +//! instance, a call that takes several steps finishes, and a canceled run +//! cancels its pending call and deletes its instance. The fixture's +//! counters show what the library still holds. + +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use harness_plugin_loader::{PluginReport, insert_plugin_dir}; +use runvil::effect::Round; +use runvil::harness::{InferenceBroker, MemoryRecorder, Packs, Timer}; +use runvil::model::{ + Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, + make_completion_error, phrase, +}; +use runvil::vfs::{MemoryBackend, Op, Origin, Policy, Verdict, VfsPath, VfsRef}; +use runvil::{BoxFuture, Error, ErrorCode, Harness, Host, RunSetup}; +use runvil_plugin::PackRegistry; + +use crate::support::{already_loaded, copy_fixture, counter, remove, scratch}; + +/// The name each Host finds its prompt by. +const PROMPT: &str = "prompt.md"; + +/// A prompt that declares the fixture's Pack and runs `lua`. +fn prompt(lua: &str) -> String { + format!( + "---\nname: fixture\ndescription: calls the fixture's tools\nrunvil: 0\n\ + uses:\n - fixture/test\n---\n\n# Fixture\n\n## Only\n\n```lua\n{lua}\n```\n" + ) +} + +/// Lists no model and refuses every round, since no prompt here asks one. +struct Offline; + +impl InferenceBroker for Offline { + fn models(&self) -> BoxFuture<'static, Result> { + Box::pin(async { Ok(ModelCatalog::empty()) }) + } + + fn chat( + &self, + _binding: ModelBinding, + _messages: Vec, + _tools: Vec, + _options: CompletionOptions, + _round: Round, + ) -> BoxFuture<'static, Result, Error>> { + let code = ErrorCode::Unavailable; + Box::pin(async move { Err(make_completion_error(code, phrase(code))) }) + } +} + +/// Sleeps on tokio's timer. +struct Clock; + +impl Timer for Clock { + fn sleep(&self, duration: Duration) -> BoxFuture<'static, ()> { + Box::pin(tokio::time::sleep(duration)) + } +} + +/// A Host over the library Plugins in `dir` that finds `source` by +/// [`PROMPT`], and the report of what `dir` loaded. +fn host_over(dir: &Path, source: String) -> (Arc, PluginReport) { + let mut packs = PackRegistry::new(); + let report = insert_plugin_dir(&mut packs, dir).expect("the directory reads"); + assert!(report.failures().is_empty(), "{:?}", report.failures()); + let host = Arc::new(Host { + packs: Packs::new(packs), + prompts: Arc::new(move |name: &str| { + if name == PROMPT { + return Ok(source.clone()); + } + let code = ErrorCode::NotFound; + Err(Error::new( + code, + format!("no prompt named {name}"), + code.prose(), + )) + }), + timer: Arc::new(Clock), + }); + (host, report) +} + +/// A Harness for one run of the Host's prompt. +fn harness(host: &Arc) -> Harness { + harness_over(host, VfsRef::default()) +} + +/// A Harness for one run of the Host's prompt over the files in `vfs`. +fn harness_over(host: &Arc, vfs: VfsRef) -> Harness { + let prompt = host.parse(PROMPT).expect("the prompt parses"); + let setup = RunSetup { + vfs, + ..RunSetup::new(Arc::new(MemoryRecorder::new()), Arc::new(Offline)) + }; + Harness::new(Arc::clone(host), prompt, setup) +} + +#[tokio::test] +async fn a_prompt_calls_a_one_step_tool_and_a_three_step_tool_of_the_library() { + let dir = scratch("a_prompt_calls_the_library_tools"); + let file = + std::path::absolute(copy_fixture(&dir, "plugin")).expect("the working directory is known"); + let lua = "local hello = tools.call('test/hello', { name = 'x' })\n\ + local steps = tools.call('test/steps', {})\n\ + return hello .. '|' .. steps"; + let (host, report) = host_over(&dir, prompt(lua)); + let library = already_loaded(&file).expect("the loader holds the library open"); + + for run in 1..=2 { + let report = harness(&host) + .run() + .await + .expect("the recorder takes the run"); + assert_eq!( + report.result, + Ok(r#"hello {"name":"x"}|3 steps"#.to_owned()), + "run {run}" + ); + assert_eq!( + (counter(&library, "packs"), counter(&library, "calls")), + (0, 0), + "run {run}: the run's Pack instance and its calls' state are gone when it ends" + ); + } + + drop(report); + assert_eq!( + counter(&library, "live"), + 1, + "the Host's Packs keep the Plugin loaded" + ); + drop(host); + assert_eq!( + (counter(&library, "unloads"), counter(&library, "live")), + (1, 0) + ); + drop(library); + remove(&dir); +} + +#[tokio::test] +async fn a_prompt_reads_the_files_of_its_run_through_the_librarys_file_tools() { + let dir = scratch("a_prompt_reads_the_files_of_its_run"); + let file = + std::path::absolute(copy_fixture(&dir, "plugin")).expect("the working directory is known"); + let lua = "local function tool(name, path)\n\ + \x20 return tools.call('test/' .. name, { path = path })\n\ + end\n\ + return table.concat({\n\ + \x20 tool('stat', '/notes/a.md'), tool('stat', '/notes'),\n\ + \x20 tool('list', '/notes'), tool('read', 'notes/a.md'),\n\ + \x20 tool('read', '/absent'), tool('list', '/notes/a.md'),\n\ + \x20 tool('read', '/notes'),\n\ + }, '|')"; + let (host, report) = host_over(&dir, prompt(lua)); + let library = already_loaded(&file).expect("the loader holds the library open"); + + let vfs = VfsRef::default(); + let seed = vfs + .acquire(Origin::new("fixture seed")) + .expect("a fresh memory filesystem acquires"); + seed.write("/notes/a.md", b"hello") + .expect("a seeded file is written"); + seed.write("/notes/sub/b.md", b"") + .expect("a seeded file is written"); + drop(seed); + + let report_of_run = harness_over(&host, vfs) + .run() + .await + .expect("the recorder takes the run"); + assert_eq!( + report_of_run.result, + Ok([ + "file 5", + "directory", + "a.md,sub/", + "hello", + "failed (1): not found: /absent", + "failed (2): not a directory: /notes/a.md", + "failed (3): is a directory: /notes", + ] + .join("|")) + ); + assert_eq!( + (counter(&library, "packs"), counter(&library, "calls")), + (0, 0) + ); + + drop((host, report)); + assert_eq!(counter(&library, "live"), 0); + drop(library); + remove(&dir); +} + +/// Denies every read under `/secret`, and panics on every read under +/// `/broken`. A write passes, so a test can seed both. +struct Guard; + +impl Policy for Guard { + fn check(&self, op: Op, path: &VfsPath) -> Verdict { + if !matches!(op, Op::Read | Op::List | Op::Stat) { + return Verdict::Allow; + } + let path = path.as_str(); + if path.starts_with("/secret") { + return Verdict::Deny("This run may not read under /secret.".to_owned()); + } + assert!(!path.starts_with("/broken"), "the policy broke on {path}"); + Verdict::Allow + } +} + +#[tokio::test] +async fn a_read_the_policy_denies_or_panics_on_is_a_file_error_and_the_run_goes_on() { + let dir = scratch("a_read_the_policy_denies_or_panics_on"); + let file = + std::path::absolute(copy_fixture(&dir, "plugin")).expect("the working directory is known"); + let lua = "local function tool(name, path)\n\ + \x20 return tools.call('test/' .. name, { path = path })\n\ + end\n\ + return table.concat({\n\ + \x20 tool('read', '/secret/key'), tool('stat', '/secret/key'),\n\ + \x20 tool('list', '/secret'), tool('read', '/broken/a.md'),\n\ + \x20 tool('read', '/notes/a.md'),\n\ + }, '|')"; + let (host, report) = host_over(&dir, prompt(lua)); + let library = already_loaded(&file).expect("the loader holds the library open"); + + let vfs = VfsRef::builder() + .store("/", MemoryBackend::new()) + .policy(Guard) + .build(); + let seed = vfs + .acquire(Origin::new("fixture seed")) + .expect("a fresh memory filesystem acquires"); + for path in ["/secret/key", "/broken/a.md", "/notes/a.md"] { + seed.write(path, b"hello") + .expect("a seeded file is written"); + } + drop(seed); + + let report_of_run = harness_over(&host, vfs) + .run() + .await + .expect("the recorder takes the run"); + let denied = "failed (4): This run may not read under /secret."; + assert_eq!( + report_of_run.result, + Ok([ + denied, + denied, + denied, + "failed (0): the file operation panicked", + "hello", + ] + .join("|")) + ); + assert_eq!( + (counter(&library, "packs"), counter(&library, "calls")), + (0, 0) + ); + + drop((host, report)); + assert_eq!(counter(&library, "live"), 0); + drop(library); + remove(&dir); +} + +#[tokio::test] +async fn a_canceled_run_cancels_its_pending_call_and_deletes_its_pack_instance() { + let dir = scratch("a_canceled_run_cancels_its_pending_call"); + let file = + std::path::absolute(copy_fixture(&dir, "plugin")).expect("the working directory is known"); + let (host, report) = host_over(&dir, prompt("return tools.call('test/never', {})")); + let library = already_loaded(&file).expect("the loader holds the library open"); + + let harness = harness(&host); + let control = harness.control(); + let mut run = std::pin::pin!(harness.run()); + let mut canceled = false; + let report_of_run = loop { + tokio::select! { + biased; + report = &mut run => break report.expect("the recorder takes the run"), + () = tokio::task::yield_now() => { + // The call holds state from its first step on, and it is + // pending, so the run cannot end by itself. + if !canceled && counter(&library, "calls") == 1 { + assert_eq!(counter(&library, "packs"), 1, "the run holds its instance"); + control.cancel(); + canceled = true; + } + } + } + }; + assert!(canceled, "the call was pending when the run was canceled"); + assert!( + report_of_run.result.as_ref().is_err_and(Error::is_canceled), + "the run ends canceled: {:?}", + report_of_run.result + ); + assert_eq!( + (counter(&library, "calls"), counter(&library, "packs")), + (0, 0), + "the cancel freed the call's state, and the run's end deleted its instance" + ); + + drop((host, report)); + assert_eq!(counter(&library, "live"), 0); + drop(library); + remove(&dir); +} diff --git a/crates/harness-internal/test-plugin/tests/it/support.rs b/crates/harness-internal/test-plugin/tests/it/support.rs index b897b4652..44a6f8456 100644 --- a/crates/harness-internal/test-plugin/tests/it/support.rs +++ b/crates/harness-internal/test-plugin/tests/it/support.rs @@ -1,11 +1,14 @@ -//! Where the fixture library is, copies of it, and the scratch directories -//! the tests put the copies in. +//! Where the fixture library is, copies of it, the scratch directories +//! the tests put the copies in, and a second handle on a library the +//! loader opened, through which a test reads the fixture's counters. use std::env::consts::{DLL_EXTENSION, DLL_PREFIX, DLL_SUFFIX}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +use libloading::Library; + /// Tells apart the directories one process makes; nextest runs each test /// in its own process, so the process id tells those apart. static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0); @@ -50,3 +53,34 @@ pub(crate) fn copy_fixture(dir: &Path, stem: &str) -> PathBuf { pub(crate) fn remove(dir: &Path) { fs::remove_dir_all(dir).expect("the scratch directory is removed, so no library is still open"); } + +/// Opens the library at the absolute `path` only when the process already +/// has it loaded. The handle adds a reference, so the library stays mapped +/// until the handle drops. +#[cfg(windows)] +pub(crate) fn already_loaded(path: &Path) -> Result { + libloading::os::windows::Library::open_already_loaded(path).map(Library::from) +} + +/// Opens the library at the absolute `path` only when the process already +/// has it loaded. The handle adds a reference, so the library stays mapped +/// until the handle drops. +#[cfg(unix)] +pub(crate) fn already_loaded(path: &Path) -> Result { + // SAFETY: with `RTLD_NOLOAD` the call loads nothing, so no library + // initializer runs; it only adds a reference to one already loaded. + let library = unsafe { + libloading::os::unix::Library::open(Some(path), libc::RTLD_NOLOAD | libc::RTLD_LAZY) + }; + library.map(Library::from) +} + +/// Calls the counter the fixture exports as `harness_test_plugin_` +/// through `library`. +pub(crate) fn counter(library: &Library, name: &str) -> u32 { + let symbol = format!("harness_test_plugin_{name}"); + // SAFETY: the fixture exports each counter as `extern "C" fn() -> u32`. + let read = unsafe { library.get:: u32>(symbol.as_bytes()) } + .unwrap_or_else(|error| panic!("the fixture exports {symbol}: {error}")); + read() +} diff --git a/crates/plugin-std/AGENTS.md b/crates/plugin-std/AGENTS.md deleted file mode 100644 index d3b8dd25a..000000000 --- a/crates/plugin-std/AGENTS.md +++ /dev/null @@ -1 +0,0 @@ -- Source code for each tool of this Plugin goes in its own directory named after the Lua name of the tool diff --git a/crates/plugin-std/Cargo.toml b/crates/plugin-std/Cargo.toml deleted file mode 100644 index aeeded052..000000000 --- a/crates/plugin-std/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "plugin-std" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -# The Plugin contract: `PackInfo`, the Pack trait, the context a -# call is lent, and the tool, error, and service vocabulary. -runvil-plugin.workspace = true -# Each tool's definition is embedded JSON, read into a spec when the Pack -# is made. -serde.workspace = true -serde_json.workspace = true -# Grep compiles its pattern with Rust regex syntax. -regex.workspace = true - -[dev-dependencies] -# `testing::TestCall`, which lends the tools a context without a Harness. -runvil-plugin = { workspace = true, features = ["test-support"] } - -[lints] -workspace = true diff --git a/crates/plugin-std/schemas/EDITS.md b/crates/plugin-std/schemas/EDITS.md deleted file mode 100644 index 206cce729..000000000 --- a/crates/plugin-std/schemas/EDITS.md +++ /dev/null @@ -1,122 +0,0 @@ -# Definition edits - -Each file in this directory except `dir.json` is Cursor's definition of the -tool named by its `wire_name`, snapshot on 2026-10-27. Everything in such a -file is Cursor's text except the edits below, each made because the line -describes behavior this Pack does not have. `dir.json` is this crate's own. - -## read.json - -- `Reads a file from the local filesystem.` became `Reads a file from the - run's filesystem.`, because every read goes through the run's virtual - filesystem, not the real one. -- `Assume this tool is able to read all files on the machine.` became - `Assume this tool is able to read every file the run can see.`, for the - same reason. -- `If the User provides a path to a file` became `If the prompt provides a - path to a file`, because a Runvil run has a prompt rather than a chat - user. -- Removed: `Image Support`, `PDF Support`, and the paragraph explaining how - images and PDFs are returned. This build reads text only, so a model told - otherwise would send a PDF and get an error. -- Removed `Results are returned using cat -n format, with line numbers - starting at 1`. The description now states the format Read really uses: a - six-wide right-aligned number, a pipe, and the line's text, where the - number and the pipe are not part of the line. -- Removed `Any lines longer than 2000 characters will be truncated`. Read - cuts a line only when that line alone passes the byte cap, and the - description says so. -- Added lines that state what Read does: it opens any path the call names - inside what the run has mounted, and reads through a link unless the link - leaves the run's filesystem; a result holds at most `{byte_cap}` bytes of - numbered lines, and a read that stops early names the offset to continue - from; only text is read, so a file over `{file_ceiling}` bytes and a binary - file are refused. The two placeholders are filled when the definition is - built, from the constants the code enforces, so the text and the limits - cannot drift apart. -- The `offset` and `limit` types are `integer`, where Cursor's were - `number`, because a line count is whole. -- The `offset` text now says that 1 is the first line, that 0 reads as 1, and - that a negative value counts back from the end with -1 as the last line. - -## write.json - -- Cursor's `Write` description, which is about creating or overwriting a - file. Reworded to name `StrReplace` for targeted changes and to require a - read before overwriting, matching Cursor's own guidance. No behavior was - added; only the wording is ours. - -## replace.json - -- Cursor's `StrReplace` definition. The wording is ours; the parameters, - their types, and the `required` list are Cursor's. - -## rm.json - -- Verbatim. Cursor's `Delete` description and schema are kept exactly. - -## glob.json - -- The `target_directory` description's default, `Cursor workspace root`, - became `the run's filesystem root`. - -## dir.json - -- This file is new. It is not a Cursor snapshot, so none of its text is - Cursor's. Cursor's counterpart, `LS`, is deprecated and gated behind a - setting, so the wire name is `ListDir` rather than Cursor's. The two - parameters, `target_directory` and `ignore_globs`, keep the names `LS` - uses. -- The description states what this Pack's tool does: a bullet tree with - directories ending in `/`, one byte-ordered list for files and - directories, a breadth-first walk that stops at `{body_cap}` bytes with a - note, `.git` skipped and every other dotfile listed, links listed and not - entered, and `ignore_globs` matching at any depth. The placeholder is - filled when the definition is built, from the constant the code - enforces, so the text and the limit cannot drift apart. -- Dropped from `LS`: the extension-count summaries, the 5-second timeout, - the terminal-log special case, and the `Error listing directory:` - prefix. - -## grep.json - -- The parameter set is now Cursor's bundle schema plus `literal`: `pattern`, - `path`, `glob`, `output_mode`, `-B`, `-A`, `-C`, `-i`, `type`, `head_limit`, - `offset`, `multiline`, and `literal`, in that order. The hyphenated names - are kept and no alias is added. -- Deleted `-n` and `context`. `-n` is gone because Grep always shows line - numbers, and a call that still passes `-n: true` is accepted while any - other `-n` value is refused. `-C` now takes the text `context` had, "Number - of lines to show before and after each match. Requires output_mode: - "content", ignored otherwise.", and `context` is refused as an undeclared - key. -- Added `literal`, a boolean that defaults to false: the pattern is matched as - plain text, so a pattern such as `foo(` needs no escape. -- `output_mode` keeps Cursor's default, `content`, and Cursor's enum. Its - text does not mention `-n`. -- `head_limit` has no default and `"minimum": 0`. Cursor's text says it - defaults to 250 and that 0 is unlimited, and this one says that 0 means no - limit and so does leaving it out. Its text says it counts matching lines in - `content` mode, files in `files_with_matches`, and file rows in `count`. -- `path` reads `File or directory to search in, as an absolute path. Defaults - to the run's filesystem root.`, where Cursor's text says `Cursor workspace - root`. -- `glob` reads `Glob pattern to filter files, for example "*.js" or - "**/*.tsx". Only * and ** are special, and matching is case-sensitive. A - pattern that does not start with "/" is matched at any depth. A pattern - that starts with "/" is a full path.`, because `{ts,tsx}` and the other - shell forms are not special in this Pack's matcher. -- The description is new and states what this Pack's Grep does: Rust regex - syntax with no look-around and no back-references, and how to escape or set - `literal`; path order, the order Glob uses; output that stops at - `{body_cap}` bytes; at most 100000 files considered; a file over - `{file_ceiling}` bytes not searched; binary files, symbolic links, over-size - files, and unreadable entries skipped and counted in the result; only - entries named `.git` skipped, with `.gitignore` rules not applied, so - `target/` and `node_modules/` are searched when they lie under `path`; the - `N:` and `N-` line prefixes and that `N` is the number Read shows; a line - over 1000 bytes shown as a window around its first match; and that paths - are absolute and work as the `path` argument of the other file tools. The - two placeholders are filled when the definition is built, from the - constants the code enforces, so the text and the limits cannot drift apart. -- The description names neither `rg` nor the `Shell` tool. \ No newline at end of file diff --git a/crates/plugin-std/schemas/dir.json b/crates/plugin-std/schemas/dir.json deleted file mode 100644 index 1e74e47e8..000000000 --- a/crates/plugin-std/schemas/dir.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "wire_name": "ListDir", - "description": "Lists a directory of the run's filesystem as a tree. Each entry is a bullet line, `- name`, the entries of a directory are indented two spaces under it, and a directory's name ends in `/`.\n\nUsage:\n- Files and directories share one list, in byte order of the name with `/` added to a directory's name, so `B` comes before `a`.\n- The walk is breadth first, so the entries closest to target_directory come first. The output holds at most {body_cap} bytes of lines. When it stops early, a directory that was not expanded ends in `...`, and a note at the end says the listing is too large. Call again with a more specific target_directory or with ignore_globs to see the rest.\n- Entries named `.git` are skipped. Every other dotfile is listed.\n- A link is listed by its name and is not entered.\n- ignore_globs leave entries out. A pattern with no leading `/` matches at any depth, so `node_modules` or `*.log` leaves out every match below target_directory. A pattern with a leading `/` is a full path from the root of the run's filesystem. Only `*` and `**` are special.", - "parameters": { - "type": "object", - "properties": { - "target_directory": { - "type": "string", - "description": "The absolute path of the directory to list" - }, - "ignore_globs": { - "type": "array", - "items": { "type": "string" }, - "description": "Globs of entries to leave out of the listing. A pattern with no leading `/` matches at any depth, and only `*` and `**` are special." - } - }, - "required": ["target_directory"] - } -} diff --git a/crates/plugin-std/schemas/glob.json b/crates/plugin-std/schemas/glob.json deleted file mode 100644 index f51f6b32b..000000000 --- a/crates/plugin-std/schemas/glob.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "wire_name": "Glob", - "description": "Finds files by name pattern and returns their paths. Use this to locate files when you know part of a name or an extension but not the directory, and prefer it over listing directories one at a time.", - "parameters": { - "type": "object", - "properties": { - "glob_pattern": { - "type": "string", - "description": "The glob pattern to match files against" - }, - "target_directory": { - "type": "string", - "description": "The absolute path to the directory to search in. If not provided, the run's filesystem root is used." - } - }, - "required": ["glob_pattern"] - } -} diff --git a/crates/plugin-std/schemas/grep.json b/crates/plugin-std/schemas/grep.json deleted file mode 100644 index 1046fd8eb..000000000 --- a/crates/plugin-std/schemas/grep.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "wire_name": "Grep", - "description": "Searches file contents with a regular expression and reports the matches. Use it to find where a symbol, string, or pattern occurs, and read the matching file before changing it.\n\nThe pattern uses Rust regex syntax, with no look-around and no back-references. Escape ( [ { . when you mean them literally, or set literal to true to match the pattern as plain text. Matching is case-sensitive unless -i is set.\n\nFiles are searched in path order, the same order Glob uses: directories are entered depth first, and the names in each directory are in byte order. Output stops at {body_cap} bytes, and at most 100000 files are considered in one search. A file over {file_ceiling} bytes is not searched. Binary files, symbolic links, over-size files, and entries that cannot be read are skipped, and the result says how many were skipped and why.\n\nOnly entries named .git are skipped, and .gitignore rules are not applied, so output such as target/ and node_modules/ is searched when it lies under path. Narrow path, glob, or type to keep it out.\n\nIn content mode each match line is shown as N:text and each context line as N-text, under the absolute path of its file. The N and the colon or hyphen are not part of the line, and N is the line number Read shows. A line over 1000 bytes is shown as a window around its first match, with markers that give the bytes left out.\n\nPaths in the result are absolute and work as the path argument of the other file tools.", - "parameters": { - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "The regular expression pattern to search for in file contents" - }, - "path": { - "type": "string", - "description": "File or directory to search in, as an absolute path. Defaults to the run's filesystem root." - }, - "glob": { - "type": "string", - "description": "Glob pattern to filter files, for example \"*.js\" or \"**/*.tsx\". Only * and ** are special, and matching is case-sensitive. A pattern that does not start with \"/\" is matched at any depth. A pattern that starts with \"/\" is a full path." - }, - "output_mode": { - "type": "string", - "enum": ["content", "files_with_matches", "count"], - "default": "content", - "description": "Output mode: \"content\" shows matching lines (supports -A/-B/-C context, head_limit), \"files_with_matches\" shows file paths (supports head_limit), \"count\" shows match counts (supports head_limit). Defaults to \"content\"." - }, - "-B": { - "type": "number", - "description": "Number of lines to show before each match. Requires output_mode: \"content\", ignored otherwise." - }, - "-A": { - "type": "number", - "description": "Number of lines to show after each match. Requires output_mode: \"content\", ignored otherwise." - }, - "-C": { - "type": "number", - "description": "Number of lines to show before and after each match. Requires output_mode: \"content\", ignored otherwise." - }, - "-i": { - "type": "boolean", - "description": "Case insensitive search" - }, - "type": { - "type": "string", - "description": "File type to search. Common types: js, py, rust, go, java, etc. More efficient than glob for standard file types." - }, - "head_limit": { - "type": "number", - "minimum": 0, - "description": "Limit output to first N lines/entries. Works across all output modes: content (limits matching lines), files_with_matches (limits file paths), count (limits count entries). Pass 0 for no limit, which is also what leaving it out means." - }, - "offset": { - "type": "number", - "description": "Skip first N lines/entries before applying head_limit. Works across all output modes. Defaults to 0." - }, - "multiline": { - "type": "boolean", - "description": "Enable multiline mode where . matches newlines and patterns can span lines. Default: false." - }, - "literal": { - "type": "boolean", - "default": false, - "description": "Match the pattern as plain text instead of as a regular expression, so ( [ { . and the rest need no escape. Default: false." - } - }, - "required": ["pattern"] - } -} diff --git a/crates/plugin-std/schemas/read.json b/crates/plugin-std/schemas/read.json deleted file mode 100644 index 7f6bdb502..000000000 --- a/crates/plugin-std/schemas/read.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "wire_name": "Read", - "description": "Reads a file from the run's filesystem. You can access any file directly by using this tool.\nAssume this tool is able to read every file the run can see. If the prompt provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.\n\nUsage:\n- Read opens any path the call names inside what the run has mounted. A link is read through unless it leaves the run's filesystem.\n- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters\n- Each line comes back as a six-wide right-aligned line number, a pipe, and the line's text, like ` 1|first line`. The number and the pipe are not part of the line.\n- A result holds at most {byte_cap} bytes of numbered lines. When a read stops before the last line, its last line names the offset to pass to continue. A single line longer than that is cut, and no parameter reads the rest of it.\n- Only text is read. A file over {file_ceiling} bytes is refused, and so is a binary file.\n- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.\n- If you read a file that exists but has empty contents you will receive 'File is empty.'", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "The absolute path of the file to read" - }, - "offset": { - "type": "integer", - "description": "The line number to start reading from. Only provide if the file is too large to read at once. 1 is the first line, 0 reads as 1, and a negative value counts back from the end, so -1 is the last line." - }, - "limit": { - "type": "integer", - "description": "The number of lines to read. Only provide if the file is too large to read at once." - } - }, - "required": ["path"] - } -} diff --git a/crates/plugin-std/schemas/replace.json b/crates/plugin-std/schemas/replace.json deleted file mode 100644 index aa7718ec7..000000000 --- a/crates/plugin-std/schemas/replace.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "wire_name": "StrReplace", - "description": "Replaces an exact string in a file with a new string. The old string must be unique in the file unless replace_all is true, so include enough surrounding context to identify the one occurrence you mean. Read the file first.", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "The absolute path of the file to modify" - }, - "old_string": { - "type": "string", - "description": "The text to replace" - }, - "new_string": { - "type": "string", - "description": "The text to replace it with (must be different from old_string)" - }, - "replace_all": { - "type": "boolean", - "description": "Replace all occurrences of old_string (default false)" - } - }, - "required": ["path", "old_string", "new_string"] - } -} diff --git a/crates/plugin-std/schemas/rm.json b/crates/plugin-std/schemas/rm.json deleted file mode 100644 index 5acd23873..000000000 --- a/crates/plugin-std/schemas/rm.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "wire_name": "Delete", - "description": "Deletes a file at the specified path. The operation will fail gracefully if:\n - The file doesn't exist\n - The operation is rejected for security reasons\n - The file cannot be deleted", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "The absolute path of the file to delete" - } - }, - "required": ["path"] - } -} diff --git a/crates/plugin-std/schemas/write.json b/crates/plugin-std/schemas/write.json deleted file mode 100644 index e9ca3908f..000000000 --- a/crates/plugin-std/schemas/write.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "wire_name": "Write", - "description": "Creates or overwrites a file at the specified path. Use this tool to create a new file or to replace the whole contents of an existing one. If the destination file already exists, you MUST first read it before writing, so a change you did not make is never overwritten. For a targeted change to an existing file, prefer StrReplace.", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "The absolute path of the file to write" - }, - "contents": { - "type": "string", - "description": "The contents to write to the file" - } - }, - "required": ["path", "contents"] - } -} diff --git a/crates/plugin-std/src/check-tests.rs b/crates/plugin-std/src/check-tests.rs deleted file mode 100644 index 31f91e54a..000000000 --- a/crates/plugin-std/src/check-tests.rs +++ /dev/null @@ -1,355 +0,0 @@ -//! Argument checking: a matching call passes, and each kind of mismatch -//! fails naming the parameter. - -use runvil_plugin::ErrorCode; -use serde_json::json; - -use super::{check, make_invalid_pattern_error, plural, refuse_blank, refuse_unknown}; -use crate::matcher::PatternError; - -/// The schema the `StrReplace` tool ships, which has a required string, an -/// optional string, and an optional boolean. -fn replace() -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "The path." }, - "old_string": { "type": "string", "description": "The old text." }, - "new_string": { "type": "string", "description": "The new text." }, - "replace_all": { "type": "boolean", "description": "Every occurrence." } - }, - "required": ["path", "old_string", "new_string"] - }) -} - -/// The refusal of `args` against `schema`, as the message and code. -fn refused(schema: &serde_json::Value, args: &serde_json::Value) -> (String, ErrorCode) { - let Err(error) = check("StrReplace", schema, args) else { - panic!("{args} is refused"); - }; - (error.to_string(), error.code) -} - -#[test] -fn matching_arguments_pass() { - let full = json!({ - "path": "/a.md", - "old_string": "one", - "new_string": "two", - "replace_all": true - }); - assert!(check("StrReplace", &replace(), &full).is_ok(), "{full}"); - let least = json!({ "path": "/a.md", "old_string": "one", "new_string": "two" }); - assert!(check("StrReplace", &replace(), &least).is_ok(), "{least}"); -} - -#[test] -fn a_null_argument_list_reads_as_an_empty_object() { - let schema = json!({ "type": "object", "properties": {} }); - assert!(check("Read", &schema, &json!(null)).is_ok()); -} - -#[test] -fn a_missing_required_parameter_fails_naming_it() { - let args = json!({ "path": "/a.md", "old_string": "one" }); - let (message, kind) = refused(&replace(), &args); - assert_eq!( - message, - "std_StrReplace: `new_string` is required, and the call left it out" - ); - assert_eq!(kind, ErrorCode::ToolInvalidArguments); -} - -#[test] -fn a_required_parameter_passed_as_null_fails_naming_it() { - let args = json!({ "path": null, "old_string": "one", "new_string": "two" }); - let (message, kind) = refused(&replace(), &args); - assert_eq!( - message, - "std_StrReplace: `path` is required, and the call passed null" - ); - assert_eq!(kind, ErrorCode::ToolInvalidArguments); -} - -#[test] -fn a_parameter_of_the_wrong_type_fails_naming_it_and_the_value() { - let args = json!({ "path": "/a.md", "old_string": "one", "new_string": 2 }); - let (message, kind) = refused(&replace(), &args); - assert_eq!( - message, - "std_StrReplace: `new_string` must be a JSON string, and the call passed 2" - ); - assert_eq!(kind, ErrorCode::ToolInvalidArguments); -} - -#[test] -fn arguments_that_are_not_an_object_fail() { - let schema = json!({ "type": "object", "properties": {} }); - let (message, kind) = refused(&schema, &json!(["a"])); - assert_eq!( - message, - "std_StrReplace: the arguments must be a JSON object" - ); - assert_eq!(kind, ErrorCode::ToolInvalidArguments); -} - -#[test] -fn a_value_outside_an_enum_fails_naming_the_allowed_values() { - let schema = json!({ - "type": "object", - "properties": { - "output_mode": { "type": "string", "enum": ["content", "count"] } - } - }); - let (message, kind) = refused(&schema, &json!({ "output_mode": "files" })); - assert_eq!( - message, - "std_StrReplace: `output_mode` must be one of \"content\", \"count\", and the call passed \"files\"" - ); - assert_eq!(kind, ErrorCode::ToolInvalidArguments); -} - -#[test] -fn an_integer_schema_accepts_a_whole_number_and_refuses_a_fraction() { - let schema = json!({ - "type": "object", - "properties": { "limit": { "type": "integer" } } - }); - assert!(check("Read", &schema, &json!({ "limit": 3 })).is_ok()); - assert!(check("Read", &schema, &json!({ "limit": 3.5 })).is_err()); -} - -#[test] -fn an_array_schema_checks_every_element() { - let schema = json!({ - "type": "object", - "properties": { - "paths": { "type": "array", "items": { "type": "string" } } - } - }); - assert!(check("Read", &schema, &json!({ "paths": ["/a", "/b"] })).is_ok()); - assert!(check("Read", &schema, &json!({ "paths": ["/a", 2] })).is_err()); -} - -#[test] -fn a_parameter_the_schema_does_not_declare_passes_so_the_tool_can_see_it() { - let args = json!({ "path": "/a.md", "old_string": "one", "new_string": "two", "x": 1 }); - assert!( - check("StrReplace", &replace(), &args).is_ok(), - "extra arguments reach the tool, which refuses them itself" - ); -} - -/// The schema the `Read` tool ships, whose properties are `path`, -/// `offset`, and `limit`. -fn read() -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "path": { "type": "string" }, - "offset": { "type": "integer" }, - "limit": { "type": "integer" } - }, - "required": ["path"] - }) -} - -#[test] -fn an_undeclared_key_is_refused_naming_it_and_listing_the_parameters_sorted() { - let args = json!({ "path": "/a.md", "lines": 3 }); - let Err(error) = refuse_unknown("Read", &read(), &args) else { - panic!("{args} is refused"); - }; - assert_eq!( - error.to_string(), - "std_Read: `lines` is not a parameter of this tool. \ - The parameters are `limit`, `offset` and `path`." - ); - assert_eq!(error.code, ErrorCode::ToolInvalidArguments); -} - -#[test] -fn the_parameter_list_is_sorted_bytewise_whatever_order_the_schema_holds() { - let schema = json!({ - "type": "object", - "properties": { "zeta": {}, "alpha": {}, "Mid": {} } - }); - let Err(error) = refuse_unknown("Grep", &schema, &json!({ "x": 1 })) else { - panic!("x is refused"); - }; - assert_eq!( - error.to_string(), - "std_Grep: `x` is not a parameter of this tool. \ - The parameters are `Mid`, `alpha` and `zeta`." - ); -} - -#[test] -fn two_parameters_are_joined_with_and_and_one_parameter_has_no_and() { - let two = json!({ "type": "object", "properties": { "b": {}, "a": {} } }); - let Err(error) = refuse_unknown("Read", &two, &json!({ "c": 1 })) else { - panic!("c is refused"); - }; - assert!( - error - .to_string() - .ends_with("The parameters are `a` and `b`."), - "{error}" - ); - let one = json!({ "type": "object", "properties": { "a": {} } }); - let Err(error) = refuse_unknown("Read", &one, &json!({ "c": 1 })) else { - panic!("c is refused"); - }; - assert!( - error.to_string().ends_with("The parameters are `a`."), - "{error}" - ); -} - -#[test] -fn declared_keys_a_null_argument_list_and_a_null_value_pass_the_unknown_key_check() { - assert!(refuse_unknown("Read", &read(), &json!({ "path": "/a.md", "limit": 2 })).is_ok()); - assert!(refuse_unknown("Read", &read(), &json!(null)).is_ok()); - assert!(refuse_unknown("Read", &read(), &json!({ "path": "/a.md", "limit": null })).is_ok()); -} - -#[test] -fn a_null_value_under_an_undeclared_key_is_still_refused() { - let args = json!({ "path": "/a.md", "lines": null }); - assert!(refuse_unknown("Read", &read(), &args).is_err(), "{args}"); -} - -#[test] -fn the_smallest_undeclared_key_is_the_one_named() { - let args = json!({ "path": "/a.md", "zz": 1, "aa": 2, "mm": 3 }); - let Err(error) = refuse_unknown("Read", &read(), &args) else { - panic!("{args} is refused"); - }; - assert!( - error.to_string().contains("`aa` is not a parameter"), - "{error}" - ); -} - -#[test] -fn a_schema_without_properties_refuses_no_key() { - let schema = json!({ "type": "object" }); - assert!(refuse_unknown("Read", &schema, &json!({ "x": 1 })).is_ok()); -} - -#[test] -fn a_schema_with_an_empty_property_list_says_the_tool_takes_none() { - let schema = json!({ "type": "object", "properties": {} }); - let Err(error) = refuse_unknown("Read", &schema, &json!({ "x": 1 })) else { - panic!("x is refused"); - }; - assert_eq!( - error.to_string(), - "std_Read: `x` is not a parameter of this tool. This tool takes no parameters." - ); -} - -#[test] -fn a_blank_value_fails_naming_the_parameter_and_echoing_the_value() { - let Err(error) = refuse_blank("Read", "path", " ") else { - panic!("whitespace is refused"); - }; - assert_eq!( - error.to_string(), - "std_Read: `path` is empty or only whitespace, and the call passed \" \"" - ); - assert_eq!(error.code, ErrorCode::ToolInvalidArguments); -} - -#[test] -fn an_empty_or_whitespace_only_value_is_blank_and_any_text_is_not() { - for blank in ["", " ", "\t\n"] { - assert!(refuse_blank("Read", "path", blank).is_err(), "{blank:?}"); - } - for text in ["a", " a ", "/notes.md"] { - assert!(refuse_blank("Read", "path", text).is_ok(), "{text:?}"); - } -} - -#[test] -fn plural_adds_s_for_every_count_but_one() { - assert_eq!(plural(1, "line"), "1 line"); - assert_eq!(plural(10, "line"), "10 lines"); - assert_eq!(plural(0, "line"), "0 lines"); - assert_eq!( - plural(2, "invalid UTF-8 sequence"), - "2 invalid UTF-8 sequences" - ); -} - -#[test] -fn plural_turns_a_consonant_then_y_into_ies_and_keeps_a_vowel_then_y() { - assert_eq!(plural(1, "directory"), "1 directory"); - assert_eq!(plural(2, "directory"), "2 directories"); - assert_eq!(plural(0, "entry"), "0 entries"); - assert_eq!(plural(3, "key"), "3 keys"); - assert_eq!(plural(5, "day"), "5 days"); -} - -#[test] -fn a_bad_pattern_names_the_parameter_echoes_the_pattern_and_gives_the_reason() { - let error = - make_invalid_pattern_error("ListDir", "ignore_globs", "a***", PatternError::StarRun); - assert_eq!( - error.to_string(), - "std_ListDir: `ignore_globs` is invalid: \"a***\" does not parse: \ - more than two consecutive '*' are not supported." - ); - assert_eq!(error.code, ErrorCode::ToolInvalidArguments); -} - -#[test] -fn a_bad_pattern_gives_each_reason_text_as_the_matcher_words_it() { - for error in [ - PatternError::Empty, - PatternError::TooLong, - PatternError::Control, - PatternError::Backslash, - PatternError::StarRun, - PatternError::PartialDoubleStar, - ] { - let text = make_invalid_pattern_error("Grep", "glob", "x", error).to_string(); - assert_eq!( - text, - format!( - "std_Grep: `glob` is invalid: \"x\" does not parse: {}.", - error.reason() - ) - ); - } -} - -#[test] -fn a_pattern_over_80_bytes_is_echoed_cut_with_dots_and_80_bytes_exactly_is_not() { - let eighty = "a".repeat(80); - let kept = make_invalid_pattern_error("ListDir", "ignore_globs", &eighty, PatternError::Empty); - assert!( - kept.to_string().contains(&format!("\"{eighty}\" does")), - "{kept}" - ); - let long = "a".repeat(81); - let cut = make_invalid_pattern_error("ListDir", "ignore_globs", &long, PatternError::Empty); - assert!( - cut.to_string().contains(&format!("\"{eighty}...\" does")), - "{cut}" - ); -} - -#[test] -fn a_pattern_is_cut_on_a_character_boundary() { - // 79 ASCII bytes and then a 4-byte emoji: byte 80 is inside the emoji, - // so the echo stops at byte 79 rather than splitting it. - let pattern = format!("{}\u{1f4e6}tail", "a".repeat(79)); - let text = make_invalid_pattern_error("ListDir", "ignore_globs", &pattern, PatternError::Empty) - .to_string(); - assert!( - text.contains(&format!("\"{}...\" does", "a".repeat(79))), - "{text}" - ); - assert!(!text.contains('\u{1f4e6}'), "{text}"); -} diff --git a/crates/plugin-std/src/check.rs b/crates/plugin-std/src/check.rs deleted file mode 100644 index a06ebb1bf..000000000 --- a/crates/plugin-std/src/check.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! Argument checking against a tool's own JSON Schema, and the helpers -//! the file tools share to refuse what the schema alone cannot. - -use runvil_plugin::{Error, ErrorCode, make_tool_error}; -use serde_json::Value; - -use crate::matcher::PatternError; -use crate::text::clip_to_boundary; - -/// Checks that `args` matches the `schema`, which is the `object` schema a -/// tool's definition file holds. -/// -/// Only the keywords these definitions use are honored: `type`, -/// `properties`, `required`, and `enum`, plus `items` for an array. A -/// schema this function does not understand is not a reason to refuse the -/// call, because the tool's own body decides what it can do with the value. -/// -/// # Errors -/// Returns an invalid-arguments [`Error`] naming the parameter and -/// what it expected. -pub(crate) fn check(tool: &str, schema: &Value, args: &Value) -> Result<(), Error> { - let args = if args.is_null() { - &Value::Object(serde_json::Map::new()) - } else { - args - }; - let Some(object) = args.as_object() else { - return Err(make_invalid_arguments_error( - tool, - "the arguments must be a JSON object", - )); - }; - if let Some(required) = schema.get("required").and_then(Value::as_array) { - for name in required.iter().filter_map(Value::as_str) { - match object.get(name) { - None => { - return Err(make_invalid_arguments_error( - tool, - format!("`{name}` is required, and the call left it out"), - )); - } - Some(Value::Null) => { - return Err(make_invalid_arguments_error( - tool, - format!("`{name}` is required, and the call passed null"), - )); - } - Some(_) => {} - } - } - } - let Some(properties) = schema.get("properties").and_then(Value::as_object) else { - return Ok(()); - }; - for (name, property) in properties { - let Some(value) = object.get(name).filter(|value| !value.is_null()) else { - continue; - }; - check_value(tool, name, property, value)?; - } - Ok(()) -} - -/// Checks one parameter's `value` against its `property` schema. -fn check_value(tool: &str, name: &str, property: &Value, value: &Value) -> Result<(), Error> { - if let Some(allowed) = property.get("enum").and_then(Value::as_array) - && !allowed.contains(value) - { - let allowed: Vec = allowed.iter().map(ToString::to_string).collect(); - return Err(make_invalid_arguments_error( - tool, - format!( - "`{name}` must be one of {}, and the call passed {value}", - allowed.join(", ") - ), - )); - } - let Some(expected) = property.get("type").and_then(Value::as_str) else { - return Ok(()); - }; - let matches = match expected { - "string" => value.is_string(), - "number" => value.is_number(), - "integer" => value.as_f64().is_some_and(|number| number.fract() == 0.0), - "boolean" => value.is_boolean(), - "array" => value.is_array(), - "object" => value.is_object(), - "null" => value.is_null(), - other => { - return Err(make_invalid_arguments_error( - tool, - format!("`{name}` has a schema type {other} this build does not know"), - )); - } - }; - if matches { - return check_items(tool, name, property, value); - } - Err(make_invalid_arguments_error( - tool, - format!("`{name}` must be a JSON {expected}, and the call passed {value}"), - )) -} - -/// Checks every element of `value`, when it is an array with an `items` -/// schema. -fn check_items(tool: &str, name: &str, property: &Value, value: &Value) -> Result<(), Error> { - let (Some(items), Some(elements)) = ( - property.get("items"), - value.as_array().filter(|_| property.get("items").is_some()), - ) else { - return Ok(()); - }; - for element in elements { - check_value(tool, name, items, element)?; - } - Ok(()) -} - -/// Refuses a key in `args` that the `schema`'s `properties` do not -/// declare. -/// -/// [`check`] lets an undeclared key through so the tool can decide what to -/// do with it, and the file tools refuse it here, after `check` and before -/// any other work. The message names the smallest undeclared key, and -/// lists the declared parameters sorted bytewise, so the text does not -/// depend on the order the JSON holds them in. A schema with no -/// `properties`, and arguments that are not an object, refuse nothing. -/// -/// # Errors -/// Returns an invalid-arguments [`Error`] naming the key. -pub(crate) fn refuse_unknown(tool: &str, schema: &Value, args: &Value) -> Result<(), Error> { - let (Some(properties), Some(object)) = ( - schema.get("properties").and_then(Value::as_object), - args.as_object(), - ) else { - return Ok(()); - }; - let Some(unknown) = object - .keys() - .filter(|key| !properties.contains_key(*key)) - .min() - else { - return Ok(()); - }; - let mut names: Vec<&str> = properties.keys().map(String::as_str).collect(); - names.sort_unstable(); - let quoted: Vec = names.iter().map(|name| format!("`{name}`")).collect(); - let tail = match quoted.split_last() { - None => "This tool takes no parameters.".to_owned(), - Some((last, [])) => format!("The parameters are {last}."), - Some((last, first)) => format!("The parameters are {} and {last}.", first.join(", ")), - }; - Err(make_invalid_arguments_error( - tool, - format!("`{unknown}` is not a parameter of this tool. {tail}"), - )) -} - -/// Refuses `value` when it is empty or only whitespace, naming the -/// parameter `name` and echoing the value. -/// -/// This runs before any I/O, so a blank path never reaches the -/// filesystem. -/// -/// # Errors -/// Returns an invalid-arguments [`Error`] naming the parameter. -pub(crate) fn refuse_blank(tool: &str, name: &str, value: &str) -> Result<(), Error> { - if value.trim().is_empty() { - return Err(make_invalid_arguments_error( - tool, - format!("`{name}` is empty or only whitespace, and the call passed \"{value}\""), - )); - } - Ok(()) -} - -/// The most bytes of a refused pattern the error echoes. -const PATTERN_ECHO_BYTES: usize = 80; - -/// The invalid-arguments error for the pattern `pattern`, which the -/// matcher refused with `error`, passed under the parameter `name`. -/// -/// The error echoes the pattern, cut to 80 bytes on a character boundary -/// with `...` added when it was cut, so a long pattern does not fill the -/// message. -pub(crate) fn make_invalid_pattern_error( - tool: &str, - name: &str, - pattern: &str, - error: PatternError, -) -> Error { - let shown = clip_to_boundary(pattern, 0, PATTERN_ECHO_BYTES); - let dots = if shown.len() < pattern.len() { - "..." - } else { - "" - }; - make_invalid_arguments_error( - tool, - format!( - "`{name}` is invalid: \"{shown}{dots}\" does not parse: {}.", - error.reason() - ), - ) -} - -/// `{n} {noun}` for a count, with `s` added to the noun for any `n` but 1. -/// -/// A noun that ends in a consonant and then `y` takes `ies` instead, so -/// `directory` gives `directories`. Examples: `1 line`, `10 lines`, -/// `0 lines`. -pub(crate) fn plural(n: usize, noun: &str) -> String { - if n == 1 { - return format!("{n} {noun}"); - } - if let Some(stem) = noun.strip_suffix('y') - && let Some(before) = stem.chars().next_back() - && before.is_alphabetic() - && !matches!(before.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u') - { - return format!("{n} {stem}ies"); - } - format!("{n} {noun}s") -} - -/// An invalid-arguments error whose message opens with the tool's id -/// segment. -pub(crate) fn make_invalid_arguments_error(tool: &str, reason: impl std::fmt::Display) -> Error { - make_tool_error( - ErrorCode::ToolInvalidArguments, - format!("std_{tool}: {reason}"), - ) -} - -#[cfg(test)] -#[path = "check-tests.rs"] -mod tests; diff --git a/crates/plugin-std/src/dir.rs b/crates/plugin-std/src/dir.rs deleted file mode 100644 index ffbd5389b..000000000 --- a/crates/plugin-std/src/dir.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! The `ListDir` tool: a bullet tree of a directory, listed breadth first. -//! -//! ListDir stats the directory the call names and walks it through the -//! call's [`Access`], so the run's mode and policy apply. The tree holds -//! at most 15,000 bytes of lines: when the next line would pass that, the -//! walk stops and the answer says so. Directories end in `/`, files and -//! directories share one byte-ordered list, entries named `.git` are -//! skipped, and a link is listed and never entered. The names are -//! untrusted output. - -mod args; -mod render; -mod walk; - -#[cfg(test)] -mod tests; - -use runvil_plugin::{Access, Error, ErrorCode, FileType, ToolOutput, VfsError, make_tool_error}; -use serde_json::Value; - -use crate::refusal::{missing_root, not_a_directory, refusal}; -use crate::spec::Definition; -use crate::text::BODY_CAP; - -use self::args::Request; - -/// Runs one `ListDir` call whose arguments already passed the shared -/// schema check. -/// -/// # Errors -/// Returns an invalid-arguments [`Error`] naming the parameter for a -/// bad argument, and a message error for any other failure, worded for a -/// model to read. -pub(crate) fn call( - access: &Access, - definition: &Definition, - args: &Value, -) -> Result { - let request = args::parse(definition, args)?; - list_directory(access, &definition.wire_name, &request, BODY_CAP) -} - -/// Lists the directory `request` names, with at most `cap` bytes of tree -/// lines. `tool` is the name the model called the tool by. -fn list_directory( - access: &Access, - tool: &str, - request: &Request<'_>, - cap: usize, -) -> Result { - let target = request.target; - let message = |text: String| make_tool_error(ErrorCode::ToolOther, text); - let failed = |error: VfsError| message(refusal(tool, target, &error)); - let stat = match access.stat(target) { - Ok(stat) => stat, - Err(VfsError::NotFound { .. }) => return Err(message(missing_root(target))), - Err(error) => return Err(failed(error)), - }; - check_directory(target, stat.file_type).map_err(message)?; - let tree = walk::walk(target, &request.ignore, cap, |path| access.list(path)) - .map_err(|failure| message(refusal(tool, &failure.path, &failure.error)))?; - Ok(ToolOutput::untrusted(render::render(&tree))) -} - -/// Decides from the kind of node at `path` whether ListDir goes on: a -/// directory does, and so does a link, which is walked through, and -/// anything else gets the sentence that says it is not a directory. -fn check_directory(path: &str, file_type: FileType) -> Result<(), String> { - match file_type { - FileType::Directory | FileType::Symlink => Ok(()), - _ => Err(not_a_directory(path)), - } -} diff --git a/crates/plugin-std/src/dir/args.rs b/crates/plugin-std/src/dir/args.rs deleted file mode 100644 index bd66a7d96..000000000 --- a/crates/plugin-std/src/dir/args.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! ListDir's arguments: what the call asks for, and what is refused before -//! any path is looked at. - -use runvil_plugin::Error; -use serde_json::Value; - -use crate::check::{make_invalid_pattern_error, refuse_blank, refuse_unknown}; -use crate::matcher::Matcher; -use crate::spec::Definition; - -/// What a call asks ListDir for. -#[derive(Debug)] -pub(super) struct Request<'a> { - /// The directory to list, as the model wrote it. - pub(super) target: &'a str, - /// The `ignore_globs`, compiled, in the order the call gave them. - pub(super) ignore: Vec, -} - -/// Reads the call's arguments after the shared schema check. -/// -/// The order is fixed: an undeclared key, then a blank target, then each -/// `ignore_globs` pattern in turn, so a call that breaks two rules always -/// hears about the same one. -/// -/// # Errors -/// Returns an invalid-arguments [`Error`] naming the parameter. A -/// refused pattern is echoed with the rule it broke. -pub(super) fn parse<'a>(definition: &Definition, args: &'a Value) -> Result, Error> { - let tool = definition.wire_name.as_str(); - refuse_unknown(tool, &definition.parameters, args)?; - let target = args - .get("target_directory") - .and_then(Value::as_str) - .unwrap_or_default(); - refuse_blank(tool, "target_directory", target)?; - let ignore = args - .get("ignore_globs") - .and_then(Value::as_array) - .map(|patterns| patterns.iter().filter_map(Value::as_str)) - .into_iter() - .flatten() - .map(|pattern| { - Matcher::new(pattern) - .map_err(|error| make_invalid_pattern_error(tool, "ignore_globs", pattern, error)) - }) - .collect::, Error>>()?; - Ok(Request { target, ignore }) -} diff --git a/crates/plugin-std/src/dir/render.rs b/crates/plugin-std/src/dir/render.rs deleted file mode 100644 index 6a00b073e..000000000 --- a/crates/plugin-std/src/dir/render.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! The render: turns the walk's tree into the text ListDir answers with. -//! -//! The tree prints depth first in the sorted order, one line per entry, -//! each indented two spaces per level under a `- ` bullet, with a trailing -//! `/` on a directory only. The lines are joined with `\n` and there is no -//! newline after the last one. - -use super::walk::{Node, Tree}; - -/// The note a cut listing ends with, after the ` ...` line and a blank -/// line. -const TOO_LARGE: &str = "target_directory is too large (either too many children or their \ - names are too long). Immediate children were truncated. Try a more specific \ - target_directory or ignore_globs."; - -/// The line that follows the root line when nothing is left to show. -const NO_CHILDREN: &str = "... no children found ..."; - -/// The line that marks where a cut listing stops. -const CUT_MARK: &str = " ..."; - -/// The text of `tree`. -/// -/// A root with nothing to show prints its line and then `... no children -/// found ...`. A tree the walk cut ends with the cut mark, a blank line, -/// and the note that says to narrow the call. The cut mark and the note -/// do not count against the byte cap. -pub(super) fn render(tree: &Tree) -> String { - // The walk always seeds the root as the first node and only pushes - // indexes it has added, so indexing the nodes cannot miss. A broken - // tree panics here instead of printing less than the walk found. - let root = &tree.nodes[0]; - if root.children.is_empty() && !tree.truncated { - return format!("{}\n{NO_CHILDREN}", tree.root_line); - } - let mut lines = vec![tree.root_line.clone()]; - let mut stack: Vec<(usize, usize)> = root - .children - .iter() - .rev() - .map(|child| (*child, 1)) - .collect(); - while let Some((index, depth)) = stack.pop() { - let node = &tree.nodes[index]; - lines.push(line(node, depth)); - stack.extend(node.children.iter().rev().map(|child| (*child, depth + 1))); - } - let mut text = lines.join("\n"); - if tree.truncated { - text.push('\n'); - text.push_str(CUT_MARK); - text.push_str("\n\n"); - text.push_str(TOO_LARGE); - } - text -} - -/// The line of `node` at `depth`: the indent, the bullet, the name, a `/` -/// for a directory, and `...` for a directory that was shown but never -/// expanded. -fn line(node: &Node, depth: usize) -> String { - let slash = if node.directory { "/" } else { "" }; - let unexpanded = if node.directory && !node.expanded { - "..." - } else { - "" - }; - format!( - "{}- {}{slash}{unexpanded}", - " ".repeat(2 * depth), - node.name - ) -} diff --git a/crates/plugin-std/src/dir/tests.rs b/crates/plugin-std/src/dir/tests.rs deleted file mode 100644 index f5139339b..000000000 --- a/crates/plugin-std/src/dir/tests.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Unit tests for the `ListDir` tool. This file holds the helpers the -//! topic files share: a call over a fresh in-memory filesystem, a seeded -//! tree, and the two ways to drive ListDir, through its arguments and -//! through a lowered byte cap. - -mod arguments; -mod cap; -mod ignore; -mod tree; - -use runvil_plugin::testing::TestCall; -use runvil_plugin::{Error, OutputTrust, ToolId, ToolOutput}; -use serde_json::Value; - -use super::args::Request; -use crate::matcher::Matcher; -use crate::spec::{self, Definition}; - -/// A call of `std/dir` over a fresh in-memory filesystem. -fn new_call() -> TestCall { - TestCall::new(ToolId::parse("std/dir").expect("a two-segment tool id parses")) -} - -/// A call with one small file at each of `files`, which makes every -/// directory on the way to it. -fn seeded(files: &[&str]) -> TestCall { - let call = new_call(); - for path in files { - call.context() - .access() - .write(path, b"x") - .expect("a seeded file is written"); - } - call -} - -/// The `dir` definition the Pack builds the tool from. -fn definition() -> &'static Definition { - spec::all() - .expect("every embedded definition parses") - .iter() - .find(|definition| definition.segment == "dir") - .expect("the dir definition exists") -} - -/// Calls ListDir with `args` as the Pack does: the schema check first, -/// then the tool. -fn list(call: &TestCall, args: &Value) -> Result { - let definition = definition(); - crate::check::check(&definition.wire_name, &definition.parameters, args)?; - super::call(call.context().access(), definition, args) -} - -/// Lists `target`, leaving out `ignore`, under the byte cap `cap`. -fn list_capped( - call: &TestCall, - target: &str, - ignore: &[&str], - cap: usize, -) -> Result { - let request = Request { - target, - ignore: ignore - .iter() - .map(|pattern| Matcher::new(pattern).expect("the pattern is valid")) - .collect(), - }; - super::list_directory(call.context().access(), "ListDir", &request, cap) -} - -/// The text of a successful listing, which must be untrusted output. -fn text_of(result: Result) -> String { - let output = result.expect("the listing succeeds"); - assert_eq!(output.trust(), OutputTrust::Untrusted, "{}", output.text()); - output.text().to_owned() -} - -/// The message of a failed listing. -fn failure_of(result: Result) -> String { - match result { - Ok(output) => panic!("the listing fails, but it answered {:?}", output.text()), - Err(error) => error.to_string(), - } -} diff --git a/crates/plugin-std/src/dir/tests/arguments.rs b/crates/plugin-std/src/dir/tests/arguments.rs deleted file mode 100644 index f70b9bf9e..000000000 --- a/crates/plugin-std/src/dir/tests/arguments.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! What ListDir refuses about its arguments, and in which order. - -use runvil_plugin::ErrorCode; -use serde_json::json; - -use super::{failure_of, list, new_call}; - -#[test] -fn a_missing_target_names_the_parameter() { - let call = new_call(); - let error = list(&call, &json!({})).expect_err("target_directory is required"); - assert_eq!( - error.to_string(), - "std_ListDir: `target_directory` is required, and the call left it out" - ); - assert_eq!(error.code, ErrorCode::ToolInvalidArguments); -} - -#[test] -fn a_target_that_is_not_a_string_names_the_parameter() { - let call = new_call(); - assert_eq!( - failure_of(list(&call, &json!({ "target_directory": 7 }))), - "std_ListDir: `target_directory` must be a JSON string, and the call passed 7" - ); -} - -#[test] -fn ignore_globs_must_be_an_array_of_strings() { - let call = new_call(); - assert_eq!( - failure_of(list( - &call, - &json!({ "target_directory": "/", "ignore_globs": "*.log" }) - )), - "std_ListDir: `ignore_globs` must be a JSON array, and the call passed \"*.log\"" - ); - assert_eq!( - failure_of(list( - &call, - &json!({ "target_directory": "/", "ignore_globs": ["a", 3] }) - )), - "std_ListDir: `ignore_globs` must be a JSON string, and the call passed 3" - ); -} - -#[test] -fn an_undeclared_key_is_refused_and_the_parameters_are_named() { - let call = new_call(); - let error = list(&call, &json!({ "target_directory": "/", "path": "/x" })) - .expect_err("path is not a parameter"); - assert_eq!( - error.to_string(), - "std_ListDir: `path` is not a parameter of this tool. \ - The parameters are `ignore_globs` and `target_directory`." - ); - assert_eq!(error.code, ErrorCode::ToolInvalidArguments); -} - -#[test] -fn an_undeclared_key_is_refused_before_a_blank_target() { - let call = new_call(); - assert_eq!( - failure_of(list(&call, &json!({ "target_directory": " ", "depth": 2 }))), - "std_ListDir: `depth` is not a parameter of this tool. \ - The parameters are `ignore_globs` and `target_directory`." - ); -} - -#[test] -fn a_blank_target_is_refused_before_a_bad_pattern() { - let call = new_call(); - assert_eq!( - failure_of(list( - &call, - &json!({ "target_directory": " ", "ignore_globs": ["a***"] }) - )), - "std_ListDir: `target_directory` is empty or only whitespace, and the call passed \" \"" - ); -} diff --git a/crates/plugin-std/src/dir/tests/cap.rs b/crates/plugin-std/src/dir/tests/cap.rs deleted file mode 100644 index a3eaa529d..000000000 --- a/crates/plugin-std/src/dir/tests/cap.rs +++ /dev/null @@ -1,181 +0,0 @@ -//! The byte cap: the walk is breadth first, stops when the next line would -//! pass the cap, marks what it did not expand, and ends with its note. - -use std::collections::HashSet; - -use runvil_plugin::OutputTrust; -use runvil_plugin::testing::TestCall; - -use super::{list_capped, seeded, text_of}; -use crate::text::BODY_CAP; - -/// The note the output ends with after the ` ...` line and a blank -/// line. -const TOO_LARGE: &str = "target_directory is too large (either too many children or their names \ - are too long). Immediate children were truncated. Try a more specific target_directory or \ - ignore_globs."; - -/// Two files at the top and two small directories, each with one file. -/// -/// The lines, with the bytes each counts with its newline, are the root -/// `/p/` (4), ` - a.txt` (10), ` - b/` (7), ` - c/` (7), ` - x.txt` -/// (12), and ` - y.txt` (12). -fn small() -> TestCall { - seeded(&["/p/a.txt", "/p/b/x.txt", "/p/c/y.txt"]) -} - -#[test] -fn the_default_cap_is_fifteen_thousand_bytes() { - assert_eq!(BODY_CAP, 15_000); -} - -#[test] -fn a_tree_that_fits_has_no_note() { - let call = small(); - let text = text_of(list_capped(&call, "/p", &[], 52)); - assert_eq!( - text, - "/p/\n - a.txt\n - b/\n - x.txt\n - c/\n - y.txt" - ); -} - -#[test] -fn a_tree_that_just_passes_the_cap_stops_at_the_last_line_that_fits() { - let call = small(); - let text = text_of(list_capped(&call, "/p", &[], 51)); - let expected = - format!("/p/\n - a.txt\n - b/\n - x.txt\n - c/...\n ...\n\n{TOO_LARGE}"); - assert_eq!(text, expected); -} - -#[test] -fn the_note_follows_a_dots_line_and_a_blank_line_byte_for_byte() { - let call = small(); - let text = text_of(list_capped(&call, "/p", &[], 40)); - let expected = [ - "/p/", - " - a.txt", - " - b/", - " - x.txt", - " - c/...", - " ...", - "", - TOO_LARGE, - ] - .join("\n"); - assert_eq!(text, expected); - assert!(text.ends_with("ignore_globs.")); -} - -#[test] -fn a_directory_whose_first_child_does_not_fit_is_marked_unexpanded() { - let call = small(); - let text = text_of(list_capped(&call, "/p", &[], 39)); - let expected = format!("/p/\n - a.txt\n - b/...\n - c/...\n ...\n\n{TOO_LARGE}"); - assert_eq!(text, expected); -} - -#[test] -fn the_directory_being_listed_keeps_the_children_already_added() { - // Cap 21 holds the root, `a.txt`, and `b/`. The root's `c/` does not - // fit, so the walk stops with `c` not in the tree at all, and `b` is - // shown but never expanded. - let call = small(); - let text = text_of(list_capped(&call, "/p", &[], 21)); - let expected = format!("/p/\n - a.txt\n - b/...\n ...\n\n{TOO_LARGE}"); - assert_eq!(text, expected); -} - -#[test] -fn the_walk_reaches_the_top_levels_before_going_deeper() { - // Breadth first: with room for the root's children and one more line, - // the one more line is the first child of the first directory, not a - // grandchild reached by going deep first. - let call = seeded(&["/r/a/deep/f.txt", "/r/a/g.txt", "/r/b/h.txt", "/r/z.txt"]); - let text = text_of(list_capped(&call, "/r", &[], 40)); - assert!(text.contains(" - a/\n - deep/...\n"), "{text}"); - assert!(text.contains(" - b/...\n"), "{text}"); - assert!(!text.contains("f.txt"), "{text}"); -} - -#[test] -fn nothing_fits_when_the_cap_is_smaller_than_the_root_line() { - let call = small(); - let text = text_of(list_capped(&call, "/p", &[], 3)); - assert_eq!(text, format!("/p/\n ...\n\n{TOO_LARGE}")); -} - -#[test] -fn the_note_and_the_dots_do_not_count_against_the_cap() { - // The lines that stay in the tree use at most the cap, though the - // output is longer because of the note and the `...` suffixes. - let call = small(); - let text = text_of(list_capped(&call, "/p", &[], 40)); - let kept: usize = text - .lines() - .take_while(|line| *line != " ...") - .map(|line| line.trim_end_matches("...").len() + 1) - .sum(); - assert!(kept <= 40, "{kept} bytes of tree lines in {text}"); - assert!(text.len() > 40); -} - -#[test] -fn a_truncated_listing_is_a_successful_untrusted_answer() { - let call = small(); - let output = list_capped(&call, "/p", &[], 21).expect("a truncated listing succeeds"); - assert_eq!(output.trust(), OutputTrust::Untrusted); -} - -#[test] -fn a_cap_sweep_over_multi_byte_names_never_cuts_inside_a_name_or_a_line() { - let files = [ - "/w/é/日本語/ファイル.txt", - "/w/é/ü.md", - "/w/📦pkg/readme.md", - "/w/📦pkg/📄.txt", - "/w/日本/a.md", - "/w/ñandú.rs", - "/w/z.txt", - ]; - let call = seeded(&files); - let full = text_of(list_capped(&call, "/w", &[], BODY_CAP)); - assert!(!full.contains("target_directory is too large"), "{full}"); - let names: HashSet<&str> = full - .lines() - .skip(1) - .map(|line| { - line.trim_start() - .trim_start_matches("- ") - .trim_end_matches('/') - }) - .collect(); - let all_bytes = full.len() + 1; - for cap in 0..=all_bytes { - let text = text_of(list_capped(&call, "/w", &[], cap)); - let mut lines = text.lines(); - assert_eq!(lines.next(), Some("/w/"), "cap {cap}"); - let mut kept = "/w/".len() + 1; - let mut seen_note = false; - for line in lines { - if line == " ..." { - seen_note = true; - break; - } - let stripped = line.trim_end_matches("..."); - assert!(line.starts_with(' '), "cap {cap}: {line:?}"); - let name = stripped - .trim_start() - .strip_prefix("- ") - .unwrap_or_else(|| panic!("cap {cap}: {line:?} is not a bullet line")) - .trim_end_matches('/'); - assert!(names.contains(name), "cap {cap}: {name:?} is cut in {text}"); - kept += stripped.len() + 1; - } - assert_eq!(seen_note, cap < all_bytes, "cap {cap}: {text}"); - if seen_note { - assert!(text.ends_with(TOO_LARGE), "cap {cap}: {text}"); - assert!(kept <= cap.max(4), "cap {cap}: {kept} bytes kept in {text}"); - } - } -} diff --git a/crates/plugin-std/src/dir/tests/ignore.rs b/crates/plugin-std/src/dir/tests/ignore.rs deleted file mode 100644 index 4f2f5f2e2..000000000 --- a/crates/plugin-std/src/dir/tests/ignore.rs +++ /dev/null @@ -1,185 +0,0 @@ -//! `ignore_globs`: where a pattern matches, what a match leaves out, and -//! what a refused pattern says. - -use runvil_plugin::{Entry, ErrorCode, FileType, Stat, VfsError}; -use serde_json::json; - -use super::{failure_of, list, seeded, text_of}; -use crate::dir::walk::walk; -use crate::matcher::Matcher; - -/// A tree with `node_modules`, `.log` files, and a `build` directory at -/// the root and one level down. -const FILES: [&str; 7] = [ - "/node_modules/x.js", - "/src/node_modules/y.js", - "/src/a.log", - "/b.log", - "/build/out.o", - "/src/build/z.o", - "/src/app.rs", -]; - -#[test] -fn a_pattern_with_no_leading_slash_leaves_out_entries_at_every_level() { - let call = seeded(&FILES); - let args = json!({ "target_directory": "/", "ignore_globs": ["node_modules", "*.log"] }); - let expected = "\ -/ - - build/ - - out.o - - src/ - - app.rs - - build/ - - z.o"; - assert_eq!(text_of(list(&call, &args)), expected); -} - -#[test] -fn a_pattern_with_a_leading_slash_leaves_out_only_the_entry_at_that_path() { - let call = seeded(&FILES); - let args = json!({ "target_directory": "/", "ignore_globs": ["/build"] }); - let expected = "\ -/ - - b.log - - node_modules/ - - x.js - - src/ - - a.log - - app.rs - - build/ - - z.o - - node_modules/ - - y.js"; - assert_eq!(text_of(list(&call, &args)), expected); -} - -#[test] -fn a_pattern_is_matched_against_the_path_below_the_target_as_written() { - let call = seeded(&FILES); - let args = json!({ "target_directory": "/src", "ignore_globs": ["/src/build", "a.log"] }); - assert_eq!( - text_of(list(&call, &args)), - "/src/\n - app.rs\n - node_modules/\n - y.js" - ); -} - -#[test] -fn a_pattern_can_name_a_path_below_a_relative_target() { - // The path a pattern is matched against has a leading `/` even when - // the target was written without one. - let call = seeded(&FILES); - let args = json!({ "target_directory": "src", "ignore_globs": ["/src/build"] }); - assert_eq!( - text_of(list(&call, &args)), - "/src/\n - a.log\n - app.rs\n - node_modules/\n - y.js" - ); -} - -#[test] -fn a_double_star_pattern_leaves_out_everything_below() { - let call = seeded(&FILES); - let args = json!({ "target_directory": "/", "ignore_globs": ["src/**"] }); - assert_eq!( - text_of(list(&call, &args)), - "/\n - b.log\n - build/\n - out.o\n - node_modules/\n - x.js\n - src/" - ); -} - -#[test] -fn a_matching_directory_is_not_entered() { - let lister = |path: &str| -> Result, VfsError> { - let directory = |name: &str| Entry::new(name, Stat::new(FileType::Directory, 0)); - match path { - "/p" => Ok(vec![directory("node_modules"), directory("src")]), - "/p/src" => Ok(vec![Entry::new("a.rs", Stat::new(FileType::File, 0))]), - other => panic!("the walk listed {other}"), - } - }; - let ignore = [Matcher::new("node_modules").expect("the pattern is valid")]; - let tree = walk("/p", &ignore, 15_000, lister).expect("the walk succeeds"); - assert_eq!( - crate::dir::render::render(&tree), - "/p/\n - src/\n - a.rs" - ); -} - -#[test] -fn a_pattern_that_matches_nothing_changes_nothing() { - let call = seeded(&["/p/a.md", "/p/sub/b.md"]); - let with = text_of(list( - &call, - &json!({ "target_directory": "/p", "ignore_globs": ["nothing-here"] }), - )); - let without = text_of(list(&call, &json!({ "target_directory": "/p" }))); - assert_eq!(with, without); -} - -#[test] -fn an_empty_list_of_patterns_and_a_null_one_leave_nothing_out() { - let call = seeded(&["/p/a.md"]); - let plain = text_of(list(&call, &json!({ "target_directory": "/p" }))); - for ignore in [json!([]), json!(null)] { - let args = json!({ "target_directory": "/p", "ignore_globs": ignore }); - assert_eq!(text_of(list(&call, &args)), plain, "{args}"); - } -} - -#[test] -fn a_refused_pattern_is_an_invalid_arguments_error_with_the_reason() { - let call = seeded(&["/p/a.md"]); - for (pattern, reason) in [ - ("a***", "more than two consecutive '*' are not supported"), - ("a/**b", "'**' must occupy a whole path segment"), - ("", "pattern is empty"), - ("a\\b", "pattern does not support backslash escapes"), - ("a\u{1}b", "pattern contains a control character"), - ] { - let args = json!({ "target_directory": "/p", "ignore_globs": [pattern] }); - let error = list(&call, &args).expect_err("the pattern is refused"); - assert_eq!(error.code, ErrorCode::ToolInvalidArguments, "{pattern:?}"); - assert_eq!( - error.to_string(), - format!( - "std_ListDir: `ignore_globs` is invalid: \"{pattern}\" does not parse: {reason}." - ) - ); - } -} - -#[test] -fn an_over_long_pattern_is_echoed_cut_to_80_bytes_with_dots() { - let call = seeded(&["/p/a.md"]); - let pattern = format!("{}***", "a".repeat(100)); - let args = json!({ "target_directory": "/p", "ignore_globs": [pattern] }); - assert_eq!( - failure_of(list(&call, &args)), - format!( - "std_ListDir: `ignore_globs` is invalid: \"{}...\" does not parse: \ - more than two consecutive '*' are not supported.", - "a".repeat(80) - ) - ); -} - -#[test] -fn the_first_refused_pattern_is_the_one_named() { - let call = seeded(&["/p/a.md"]); - let args = json!({ "target_directory": "/p", "ignore_globs": ["ok", "a***", "a/**b"] }); - assert_eq!( - failure_of(list(&call, &args)), - "std_ListDir: `ignore_globs` is invalid: \"a***\" does not parse: \ - more than two consecutive '*' are not supported." - ); -} - -#[test] -fn a_pattern_is_refused_before_the_path_is_looked_at() { - let call = seeded(&[]); - let args = json!({ "target_directory": "/does/not/exist", "ignore_globs": ["a***"] }); - assert_eq!( - failure_of(list(&call, &args)), - "std_ListDir: `ignore_globs` is invalid: \"a***\" does not parse: \ - more than two consecutive '*' are not supported." - ); -} diff --git a/crates/plugin-std/src/dir/tests/tree.rs b/crates/plugin-std/src/dir/tests/tree.rs deleted file mode 100644 index 82f06b228..000000000 --- a/crates/plugin-std/src/dir/tests/tree.rs +++ /dev/null @@ -1,458 +0,0 @@ -//! The shape of the tree ListDir answers with: the layout, the order, what -//! is skipped, how a link is listed, and what each failure says. - -use runvil_plugin::{Entry, FileType, OutputTrust, Stat, VfsError}; -use serde_json::json; - -use super::{failure_of, list, new_call, seeded, text_of}; -use crate::dir::check_directory; -use crate::dir::render::render; -use crate::dir::walk::{Failure, walk}; -use crate::refusal::refusal; - -/// The listing ListDir answers for `target`. -fn tree(files: &[&str], target: &str) -> String { - text_of(list(&seeded(files), &json!({ "target_directory": target }))) -} - -/// A directory entry of kind `file_type`. -fn entry(name: &str, file_type: FileType) -> Entry { - Entry::new(name, Stat::new(file_type, 0)) -} - -#[test] -fn the_posix_tree_lists_every_level_in_byte_order() { - // The parent report shows `package.json` before `README.md`, because - // Cursor sorts with `localeCompare`. Byte order governs here, so - // `README.md` comes first. - let text = tree( - &[ - "/home/dev/proj/README.md", - "/home/dev/proj/package.json", - "/home/dev/proj/docs/guide.md", - "/home/dev/proj/src/app.tsx", - "/home/dev/proj/src/index.ts", - "/home/dev/proj/src/utils/math.ts", - ], - "/home/dev/proj", - ); - let expected = "\ -/home/dev/proj/ - - README.md - - docs/ - - guide.md - - package.json - - src/ - - app.tsx - - index.ts - - utils/ - - math.ts"; - assert_eq!(text, expected); -} - -#[test] -fn the_output_has_no_trailing_newline() { - let text = tree(&["/p/a.md"], "/p"); - assert_eq!(text, "/p/\n - a.md"); - assert!(!text.ends_with('\n')); -} - -#[test] -fn an_empty_directory_says_it_has_no_children() { - let call = new_call(); - call.context() - .access() - .mkdir("/home/dev/proj/empty", true) - .expect("the directory is made"); - assert_eq!( - text_of(list( - &call, - &json!({ "target_directory": "/home/dev/proj/empty" }) - )), - "/home/dev/proj/empty/\n... no children found ..." - ); -} - -#[test] -fn a_root_holding_only_dot_git_says_it_has_no_children() { - let call = seeded(&["/repo/.git/HEAD", "/repo/.git/objects/ab"]); - assert_eq!( - text_of(list(&call, &json!({ "target_directory": "/repo" }))), - "/repo/\n... no children found ..." - ); -} - -#[test] -fn a_root_whose_children_are_all_ignored_says_it_has_no_children() { - let call = seeded(&["/p/a.log", "/p/b.log"]); - assert_eq!( - text_of(list( - &call, - &json!({ "target_directory": "/p", "ignore_globs": ["*.log"] }) - )), - "/p/\n... no children found ..." - ); -} - -#[test] -fn a_directory_that_is_empty_below_the_root_is_still_listed() { - let call = seeded(&["/p/a.md"]); - call.context() - .access() - .mkdir("/p/empty", false) - .expect("the directory is made"); - assert_eq!( - text_of(list(&call, &json!({ "target_directory": "/p" }))), - "/p/\n - a.md\n - empty/" - ); -} - -#[test] -fn a_file_as_the_target_is_not_a_directory() { - let call = seeded(&["/home/dev/proj/README.md"]); - assert_eq!( - failure_of(list( - &call, - &json!({ "target_directory": "/home/dev/proj/README.md" }) - )), - "Path is not a directory: /home/dev/proj/README.md" - ); -} - -#[test] -fn a_missing_path_does_not_exist() { - let call = new_call(); - assert_eq!( - failure_of(list(&call, &json!({ "target_directory": "/nope" }))), - "Path does not exist: /nope" - ); -} - -#[test] -fn a_failure_echoes_the_path_as_the_model_wrote_it() { - let call = seeded(&["/notes/a.md"]); - assert_eq!( - failure_of(list(&call, &json!({ "target_directory": "nope" }))), - "Path does not exist: nope" - ); - assert_eq!( - failure_of(list(&call, &json!({ "target_directory": "notes/a.md" }))), - "Path is not a directory: notes/a.md" - ); -} - -#[test] -fn a_blank_target_is_refused_before_any_io() { - let call = new_call(); - for blank in ["", " ", "\t\n"] { - assert_eq!( - failure_of(list(&call, &json!({ "target_directory": blank }))), - format!( - "std_ListDir: `target_directory` is empty or only whitespace, \ - and the call passed \"{blank}\"" - ) - ); - } -} - -#[test] -fn the_file_type_decision_covers_every_kind_of_node() { - assert_eq!(check_directory("/p", FileType::Directory), Ok(())); - assert_eq!(check_directory("/p", FileType::Symlink), Ok(())); - for other in [ - FileType::File, - FileType::Fifo, - FileType::Socket, - FileType::CharDevice, - FileType::BlockDevice, - ] { - assert_eq!( - check_directory("/p", other), - Err("Path is not a directory: /p".to_owned()), - "{other:?}" - ); - } -} - -#[test] -fn a_relative_target_gets_a_leading_slash_on_the_root_line() { - let call = seeded(&["/home/dev/a.md"]); - assert_eq!( - text_of(list(&call, &json!({ "target_directory": "home/dev" }))), - "/home/dev/\n - a.md" - ); -} - -#[test] -fn a_trailing_slash_on_the_target_is_not_doubled() { - let call = seeded(&["/home/dev/a.md"]); - assert_eq!( - text_of(list(&call, &json!({ "target_directory": "/home/dev/" }))), - "/home/dev/\n - a.md" - ); -} - -#[test] -fn the_filesystem_root_is_a_single_slash() { - let call = seeded(&["/a.md", "/b/c.md"]); - assert_eq!( - text_of(list(&call, &json!({ "target_directory": "/" }))), - "/\n - a.md\n - b/\n - c.md" - ); -} - -#[test] -fn dot_git_is_skipped_while_other_dotfiles_are_listed() { - let call = seeded(&[ - "/p/.git/HEAD", - "/p/.env", - "/p/.gitignore", - "/p/src/.hidden", - "/p/src/.git/config", - "/p/src/a.rs", - ]); - let expected = "\ -/p/ - - .env - - .gitignore - - src/ - - .hidden - - a.rs"; - assert_eq!( - text_of(list(&call, &json!({ "target_directory": "/p" }))), - expected - ); -} - -#[test] -fn a_dot_git_that_is_a_file_is_skipped_too() { - let call = seeded(&["/p/.git", "/p/a.md", "/p/sub/.git"]); - assert_eq!( - text_of(list(&call, &json!({ "target_directory": "/p" }))), - "/p/\n - a.md\n - sub/" - ); -} - -#[test] -fn a_name_that_only_starts_with_dot_git_is_listed() { - let call = seeded(&["/p/.github/ci.yml", "/p/.gitmodules"]); - assert_eq!( - text_of(list(&call, &json!({ "target_directory": "/p" }))), - "/p/\n - .github/\n - ci.yml\n - .gitmodules" - ); -} - -#[test] -fn the_sort_order_is_the_order_access_glob_gives() { - let call = seeded(&["/B", "/a-b/x", "/a.txt", "/a/x", "/b"]); - assert_eq!( - text_of(list(&call, &json!({ "target_directory": "/" }))), - "/\n - B\n - a-b/\n - x\n - a.txt\n - a/\n - x\n - b" - ); - let globbed = call - .context() - .access() - .glob("/**") - .expect("the pattern is valid"); - assert_eq!(globbed, ["/B", "/a-b/x", "/a.txt", "/a/x", "/b"]); -} - -#[test] -fn a_directory_sorts_by_its_name_with_a_slash_added() { - // `a` the directory sorts as `a/`, which is after `a-b` and `a.txt`, - // because `/` is 0x2f and `-` and `.` are lower. - let call = seeded(&["/a/x", "/a-b", "/a.txt", "/a0"]); - let text = text_of(list(&call, &json!({ "target_directory": "/" }))); - let names: Vec<&str> = text - .lines() - .skip(1) - .filter(|line| line.starts_with(" - ")) - .collect(); - assert_eq!(names, [" - a-b", " - a.txt", " - a/", " - a0"]); -} - -#[test] -fn a_symlink_is_listed_by_name_without_a_slash_and_is_never_entered() { - let lister = |path: &str| -> Result, VfsError> { - match path { - "/p" => Ok(vec![ - entry("link", FileType::Symlink), - entry("sub", FileType::Directory), - entry("file", FileType::File), - ]), - "/p/sub" => Ok(vec![entry("inner", FileType::File)]), - other => panic!("the walk entered {other}"), - } - }; - let tree = walk("/p", &[], 15_000, lister).expect("the walk succeeds"); - assert_eq!( - render(&tree), - "/p/\n - file\n - link\n - sub/\n - inner" - ); -} - -#[test] -fn every_other_kind_of_node_is_listed_as_a_plain_name() { - let lister = |_: &str| -> Result, VfsError> { - Ok(vec![ - entry("block", FileType::BlockDevice), - entry("char", FileType::CharDevice), - entry("fifo", FileType::Fifo), - entry("sock", FileType::Socket), - ]) - }; - let tree = walk("/dev", &[], 15_000, lister).expect("the walk succeeds"); - assert_eq!( - render(&tree), - "/dev/\n - block\n - char\n - fifo\n - sock" - ); -} - -#[test] -fn the_pack_sorts_each_listing_itself_because_list_promises_no_order() { - let lister = |path: &str| -> Result, VfsError> { - match path { - "/p" => Ok(vec![ - entry("b", FileType::File), - entry("a", FileType::Directory), - entry("B", FileType::File), - entry("a-b", FileType::File), - ]), - "/p/a" => Ok(vec![entry("z", FileType::File), entry("Z", FileType::File)]), - other => panic!("the walk entered {other}"), - } - }; - let tree = walk("/p", &[], 15_000, lister).expect("the walk succeeds"); - assert_eq!( - render(&tree), - "/p/\n - B\n - a-b\n - a/\n - Z\n - z\n - b" - ); -} - -#[test] -fn a_failure_listing_the_root_is_returned_for_the_tool_to_word() { - let lister = |path: &str| -> Result, VfsError> { - Err(VfsError::NotADirectory { - path: path.to_owned(), - }) - }; - let failure = walk("/link/", &[], 15_000, lister).expect_err("the root cannot be listed"); - assert_eq!( - failure, - Failure { - path: "/link/".to_owned(), - error: VfsError::NotADirectory { - path: "/link".to_owned() - }, - }, - "the root's path is the target exactly as written" - ); -} - -#[test] -fn a_subdirectory_that_is_gone_or_no_longer_a_directory_is_shown_without_children() { - let lister = |path: &str| -> Result, VfsError> { - match path { - "/p" => Ok(vec![ - entry("gone", FileType::Directory), - entry("open", FileType::Directory), - entry("swapped", FileType::Directory), - ]), - "/p/gone" => Err(VfsError::NotFound { - path: path.to_owned(), - }), - "/p/open" => Ok(vec![entry("a", FileType::File)]), - "/p/swapped" => Err(VfsError::NotADirectory { - path: path.to_owned(), - }), - other => panic!("the walk entered {other}"), - } - }; - let tree = walk("/p", &[], 15_000, lister).expect("an empty look at these is true"); - assert_eq!( - render(&tree), - "/p/\n - gone/\n - open/\n - a\n - swapped/" - ); -} - -#[test] -fn a_subdirectory_that_cannot_be_listed_for_any_other_reason_fails_the_walk() { - for label in ["conflict", "denied", "io"] { - let lister = |path: &str| -> Result, VfsError> { - match path { - "/p" => Ok(vec![ - entry("locked", FileType::Directory), - entry("open", FileType::Directory), - ]), - "/p/locked" => Err(listing_error(label, path)), - "/p/open" => Ok(vec![entry("a", FileType::File)]), - other => panic!("the walk entered {other}"), - } - }; - let failure = walk("/p", &[], 15_000, lister).expect_err(label); - assert_eq!( - failure.path, "/p/locked", - "{label}: the directory that failed" - ); - assert_eq!(failure.error, listing_error(label, "/p/locked"), "{label}"); - } -} - -/// The error a listing of `path` fails with, for the kind named by `label`. -fn listing_error(label: &str, path: &str) -> VfsError { - match label { - "conflict" => VfsError::Conflict { - path: path.to_owned(), - detail: "another call holds a write claim".to_owned(), - }, - "denied" => VfsError::PermissionDenied { - path: path.to_owned(), - reason: "Reading /p/locked is denied by policy.".to_owned(), - }, - "io" => VfsError::Io { - path: path.to_owned(), - kind: std::io::ErrorKind::PermissionDenied, - raw_os_error: None, - message: "access denied".to_owned(), - }, - other => panic!("no listing error named {other}"), - } -} - -#[test] -fn a_conflict_below_the_root_is_worded_for_the_directory_that_conflicted() { - let lister = |path: &str| -> Result, VfsError> { - match path { - "notes" => Ok(vec![entry("locked", FileType::Directory)]), - "notes/locked" => Err(VfsError::Conflict { - path: "/notes/locked".to_owned(), - detail: "another call holds a write claim".to_owned(), - }), - other => panic!("the walk entered {other}"), - } - }; - let failure = walk("notes", &[], 15_000, lister).expect_err("the conflict fails the walk"); - assert_eq!( - refusal("ListDir", &failure.path, &failure.error), - "notes/locked was changed, or is being changed, by another call in this round, so it \ - cannot be read in this round. Call ListDir again in your next message." - ); -} - -#[test] -fn every_successful_listing_is_untrusted_output() { - let call = seeded(&["/p/a.md", "/p/.git/HEAD"]); - call.context() - .access() - .mkdir("/empty", false) - .expect("the directory is made"); - for args in [ - json!({ "target_directory": "/p" }), - json!({ "target_directory": "/empty" }), - json!({ "target_directory": "/" }), - json!({ "target_directory": "/p", "ignore_globs": ["*.md"] }), - ] { - let output = list(&call, &args).expect("the listing succeeds"); - assert_eq!(output.trust(), OutputTrust::Untrusted, "{args}"); - } -} diff --git a/crates/plugin-std/src/dir/walk.rs b/crates/plugin-std/src/dir/walk.rs deleted file mode 100644 index 62fd00f91..000000000 --- a/crates/plugin-std/src/dir/walk.rs +++ /dev/null @@ -1,234 +0,0 @@ -//! The walk: lists a directory tree breadth first and stops at the byte -//! cap, so a huge tree is never read whole. -//! -//! The walk returns a [`Tree`] and writes no text. Each directory's -//! entries are sorted in the shared walk order (`crate::order`), because -//! `Access::list` promises no order. That order equals the order -//! `Access::glob` gives. - -use std::collections::VecDeque; - -use runvil_plugin::{Entry, FileType, VfsError}; - -use crate::matcher::Matcher; -use crate::order::{GIT, sorted}; - -/// One line of the tree: a name under its parent. -#[derive(Debug)] -pub(super) struct Node { - /// The entry's name. The root's is empty. - pub(super) name: String, - /// Whether the entry is a directory, which ends in `/` and is entered. - /// A link and every other kind of node is not. - pub(super) directory: bool, - /// Whether the walk got as far as showing this directory's contents. - /// It is `false` only for a directory that was cut off before its - /// first child was shown, and it prints with `...` after its name. - pub(super) expanded: bool, - /// The indexes in [`Tree::nodes`] of the entries shown under this one, - /// in sorted order. - pub(super) children: Vec, -} - -/// The tree the walk found. -#[derive(Debug)] -pub(super) struct Tree { - /// The root's line: its absolute virtual path, ending in `/`. - pub(super) root_line: String, - /// Every node shown. The first is the root. - pub(super) nodes: Vec, - /// Whether the walk stopped because the next line would pass the cap. - pub(super) truncated: bool, -} - -/// A listing that failed and ended the walk. -#[derive(Debug, PartialEq, Eq)] -pub(super) struct Failure { - /// The directory that could not be listed, written as the model wrote - /// the target with the names below it added. For the root it is the - /// target exactly as written. - pub(super) path: String, - /// Why the listing failed. - pub(super) error: VfsError, -} - -/// A directory the walk still has to list. -struct Pending { - /// Its index in the tree's nodes. - node: usize, - /// The path the lister is given for it. - path: String, - /// The depth of the entries under it: 1 for the root's. - depth: usize, -} - -/// Walks the directory `target`, breadth first, calling `list` for each -/// directory it enters. -/// -/// `target` is the path as the model wrote it. `ignore` leaves out each -/// entry whose absolute virtual path a pattern matches, and a left-out -/// directory is not entered. The walk counts the bytes of every line it -/// adds, the root's included, each with its newline, and when the next line -/// would pass `cap` it stops everything: the directory being listed keeps -/// the children already added, and every directory shown but not expanded -/// is marked. -/// -/// # Errors -/// Returns the lister's error, with the directory it was for, when the -/// root cannot be listed, and for a directory below the root when the -/// failure is anything but the directory being gone or no longer a -/// directory. A claim conflict or a refusal by policy is returned rather -/// than shown as an empty directory, because the model would read -/// `locked/` as empty when its contents were never seen. Only a directory -/// that is gone, or is no longer a directory, is shown with no children, -/// because an empty look at it is true. -pub(super) fn walk( - target: &str, - ignore: &[Matcher], - cap: usize, - mut list: F, -) -> Result -where - F: FnMut(&str) -> Result, VfsError>, -{ - let root_line = root_line(target); - let mut used = root_line.len() + 1; - let mut nodes = vec![Node { - name: String::new(), - directory: true, - expanded: true, - children: Vec::new(), - }]; - let mut queue = VecDeque::from([Pending { - node: 0, - path: directory_path(target), - depth: 1, - }]); - let mut truncated = false; - while let Some(pending) = queue.pop_front() { - let entries = match list(&pending.path) { - Ok(entries) => entries, - Err(error) if pending.node != 0 && is_gone(&error) => Vec::new(), - Err(error) => { - let path = if pending.node == 0 { - target.to_owned() - } else { - pending.path - }; - return Err(Failure { path, error }); - } - }; - for entry in visible(entries, &pending.path, ignore) { - let directory = matches!(entry.stat.file_type, FileType::Directory); - let cost = line_cost(pending.depth, &entry.name, directory); - if used + cost > cap { - truncated = true; - break; - } - used += cost; - let index = nodes.len(); - if directory { - queue.push_back(Pending { - node: index, - path: join(&pending.path, &entry.name), - depth: pending.depth + 1, - }); - } - nodes.push(Node { - name: entry.name, - directory, - expanded: !directory, - children: Vec::new(), - }); - nodes[pending.node].children.push(index); - } - let listed = &mut nodes[pending.node]; - listed.expanded = !truncated || !listed.children.is_empty(); - if truncated { - break; - } - } - Ok(Tree { - root_line, - nodes, - truncated, - }) -} - -/// Whether `error` from listing a directory below the root means the -/// directory is gone, or is no longer a directory, so showing it with no -/// children is true. -fn is_gone(error: &VfsError) -> bool { - matches!( - error, - VfsError::NotFound { .. } | VfsError::NotADirectory { .. } - ) -} - -/// The entries of one directory the tree shows, in order: everything but -/// `.git` and what `ignore` matches, in the shared walk order. -fn visible(entries: Vec, directory: &str, ignore: &[Matcher]) -> Vec { - let kept: Vec = entries - .into_iter() - .filter(|entry| entry.name != GIT && !is_ignored(directory, &entry.name, ignore)) - .collect(); - sorted(kept) -} - -/// Whether a pattern in `ignore` matches the entry `name` of `directory`, -/// by the entry's absolute virtual path, which has no trailing `/` even -/// for a directory. -fn is_ignored(directory: &str, name: &str, ignore: &[Matcher]) -> bool { - if ignore.is_empty() { - return false; - } - let path = virtual_path(&join(directory, name)); - ignore.iter().any(|matcher| matcher.is_match(&path)) -} - -/// The bytes a line costs against the cap: the indent of two spaces per -/// level, the bullet, the name, the `/` of a directory, and the newline. -fn line_cost(depth: usize, name: &str, directory: bool) -> usize { - 2 * depth + "- ".len() + name.len() + usize::from(directory) + 1 -} - -/// `name` inside `directory`, with one `/` between them. -fn join(directory: &str, name: &str) -> String { - if directory.ends_with('/') { - format!("{directory}{name}") - } else { - format!("{directory}/{name}") - } -} - -/// The path the lister is given for the root: the target as written, -/// without a trailing `/`, and `/` alone for the filesystem root. -fn directory_path(target: &str) -> String { - let trimmed = target.trim_end_matches('/'); - if trimmed.is_empty() { - "/".to_owned() - } else { - trimmed.to_owned() - } -} - -/// `path` with a leading `/` added when it lacks one. No canonical path is -/// available, so this is as far as the model's path is turned into an -/// absolute one. -fn virtual_path(path: &str) -> String { - if path.starts_with('/') { - path.to_owned() - } else { - format!("/{path}") - } -} - -/// The root's line: the target as an absolute virtual path, with `/` -/// appended unless it already ends in one. -fn root_line(target: &str) -> String { - let mut line = virtual_path(target); - if !line.ends_with('/') { - line.push('/'); - } - line -} diff --git a/crates/plugin-std/src/grep.rs b/crates/plugin-std/src/grep.rs deleted file mode 100644 index df87ed49f..000000000 --- a/crates/plugin-std/src/grep.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! The `Grep` tool: a regular-expression search over the files under a -//! path. -//! -//! Grep walks the tree through the call's [`Access`], so the run's mode -//! and policy apply, and reads each file whole, never more than 16 MiB of -//! it. Files come in path order, the order Glob uses. Output stops at -//! 15,000 bytes, and every call runs from the start, so a call with an -//! `offset` pages through the same result. An entry that cannot be -//! searched is skipped and counted, never a failure. The text of the -//! files is returned as untrusted output. - -mod args; -mod find; -mod found; -mod matching; -mod pause; -mod render; -mod source; -mod types; -mod window; - -#[cfg(test)] -mod tests; - -use runvil_plugin::{Access, Error, ErrorCode, ToolOutput, make_tool_error}; -use serde_json::Value; - -use crate::spec::Definition; - -use self::args::Request; -use self::found::Limits; -use self::source::Source; - -/// Runs one `Grep` call whose arguments already passed the shared schema -/// check. -/// -/// # Errors -/// Returns an invalid-arguments [`Error`] naming the parameter for a -/// bad argument, and a message error for a root that cannot be searched, -/// worded for a model to read. -pub(crate) async fn call( - access: &Access, - definition: &Definition, - args: &Value, -) -> Result { - let request = args::parse(definition, args)?; - search(access, &definition.wire_name, &request, &Limits::default()).await -} - -/// Searches `source` for what `request` asks, under `limits`. `tool` is -/// the name the model called the tool by. -async fn search( - source: &S, - tool: &str, - request: &Request<'_>, - limits: &Limits, -) -> Result { - let found = find::find(source, tool, request, limits) - .await - .map_err(|text| make_tool_error(ErrorCode::ToolOther, text))?; - Ok(render::render(&found, request, limits)) -} diff --git a/crates/plugin-std/src/grep/args.rs b/crates/plugin-std/src/grep/args.rs deleted file mode 100644 index 76adc1051..000000000 --- a/crates/plugin-std/src/grep/args.rs +++ /dev/null @@ -1,261 +0,0 @@ -//! Grep's arguments: what the call asks for, and what is refused before -//! any path is looked at. - -use regex::{Regex, RegexBuilder}; -use runvil_plugin::Error; -use serde_json::Value; - -use super::types; -use crate::check::{ - make_invalid_arguments_error, make_invalid_pattern_error, refuse_blank, refuse_unknown, -}; -use crate::matcher::Matcher; -use crate::spec::Definition; - -/// What Grep prints for each match. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum Mode { - /// The matching lines, with their context. - Content, - /// The paths of the files that match. - FilesWithMatches, - /// Each matching file's path and its count of matching lines. - Count, -} - -/// What a call asks Grep for. -#[derive(Debug)] -pub(super) struct Request<'a> { - /// The file or directory to search, as the model wrote it. `None` - /// means the filesystem root. - pub(super) path: Option<&'a str>, - /// The `glob` filter as the model wrote it, and compiled. - pub(super) glob: Option<(&'a str, Matcher)>, - /// The extensions the `type` filter keeps. - pub(super) kind: Option<&'static [&'static str]>, - /// What to print for each match. - pub(super) mode: Mode, - /// The context lines shown before each match, in `content` mode. - pub(super) before: usize, - /// The context lines shown after each match, in `content` mode. - pub(super) after: usize, - /// The most entries to show, or `None` for no limit. - pub(super) limit: Option, - /// The entries to skip first. - pub(super) offset: usize, - /// Whether a match may span lines, so the whole text is one subject. - pub(super) multiline: bool, - /// The compiled pattern. - pub(super) regex: Regex, -} - -/// The one key a call may pass that the schema does not declare: Cursor's -/// `-n`, which a model may still send, and which is accepted when it is -/// `true`. -const ACCEPTED_UNDECLARED: &str = "-n"; - -/// Reads the call's arguments after the shared schema check. -/// -/// The order is fixed, so a call that breaks two rules always hears about -/// the same one: an undeclared key (the one undeclared key `-n` is -/// accepted), the `-n` value, an empty pattern, a negative `-A`, `-B`, or -/// `-C`, a negative `head_limit` or `offset`, an unknown `type`, an -/// invalid `glob`, a blank `path`, and last the pattern itself, which is -/// compiled before any filesystem call. -/// -/// # Errors -/// Returns an invalid-arguments [`Error`] naming the parameter. -pub(super) fn parse<'a>(definition: &Definition, args: &'a Value) -> Result, Error> { - let tool = definition.wire_name.as_str(); - let mut declared = args.clone(); - if let Some(object) = declared.as_object_mut() { - object.remove(ACCEPTED_UNDECLARED); - } - refuse_unknown(tool, &definition.parameters, &declared)?; - refuse_line_numbers_off(tool, args)?; - let pattern = args - .get("pattern") - .and_then(Value::as_str) - .unwrap_or_default(); - if pattern.is_empty() { - return Err(make_invalid_arguments_error( - tool, - "`pattern` is empty. Pass \".\" to match every line.", - )); - } - let after = number(tool, args, "-A", "context lines")?; - let before = number(tool, args, "-B", "context lines")?; - let both = number(tool, args, "-C", "context lines")?; - let limit = number(tool, args, "head_limit", "")?.map(|value| count(value, true)); - let offset = number(tool, args, "offset", "")?.map(|value| count(value, false)); - let kind = kind(tool, args)?; - let glob = glob(tool, args)?; - let path = path(tool, args)?; - let mode = mode(args); - let (before, after) = if mode == Mode::Content { - ( - before.or(both).map_or(0, |value| count(value, true)), - after.or(both).map_or(0, |value| count(value, true)), - ) - } else { - (0, 0) - }; - let multiline = flag(args, "multiline"); - let regex = compile(tool, pattern, args, multiline)?; - Ok(Request { - path, - glob, - kind, - mode, - before, - after, - limit: limit.filter(|limit| *limit > 0), - offset: offset.unwrap_or_default(), - multiline, - regex, - }) -} - -/// Refuses `-n` unless it is `true` or left out, because Grep always shows -/// line numbers. -fn refuse_line_numbers_off(tool: &str, args: &Value) -> Result<(), Error> { - match args.get(ACCEPTED_UNDECLARED) { - None | Some(Value::Null | Value::Bool(true)) => Ok(()), - Some(_) => Err(make_invalid_arguments_error( - tool, - "`-n` must be true or left out, because line numbers are always shown.", - )), - } -} - -/// The number under `name`, or `None` when the call left it out or passed -/// `null`, refused when it is negative. -/// -/// `unit` names what a context line count counts, and is empty for -/// `head_limit` and `offset`, whose refusal is shorter. -fn number(tool: &str, args: &Value, name: &str, unit: &str) -> Result, Error> { - let Some(value) = args.get(name).filter(|value| !value.is_null()) else { - return Ok(None); - }; - let Some(number) = value.as_f64() else { - return Ok(None); - }; - if number < 0.0 { - let ask = if unit.is_empty() { - "Pass 0 or more.".to_owned() - } else { - format!("Pass 0 or more {unit}.") - }; - return Err(make_invalid_arguments_error( - tool, - format!("`{name}` is {value}. {ask}"), - )); - } - Ok(Some(number)) -} - -/// The whole number `value` rounds to, up or down. A value past the range -/// of `usize` is the largest `usize`. -#[expect( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - reason = "the value is not negative, and a float to integer cast saturates at the largest usize" -)] -fn count(value: f64, up: bool) -> usize { - let whole = if up { value.ceil() } else { value.floor() }; - whole as usize -} - -/// The extensions of the `type` the call names, or `None` when it names -/// none. An empty string names none. -fn kind(tool: &str, args: &Value) -> Result, Error> { - let Some(name) = text(args, "type") else { - return Ok(None); - }; - types::extensions(name).map(Some).ok_or_else(|| { - make_invalid_arguments_error( - tool, - format!( - "Unrecognized file type \"{name}\". Use a type such as js, ts, py, rust, go or \ - java, or filter with glob." - ), - ) - }) -} - -/// The `glob` the call names, compiled, or `None` when it names none. -fn glob<'a>(tool: &str, args: &'a Value) -> Result, Error> { - let Some(pattern) = text(args, "glob") else { - return Ok(None); - }; - Matcher::new(pattern) - .map(|matcher| Some((pattern, matcher))) - .map_err(|error| make_invalid_pattern_error(tool, "glob", pattern, error)) -} - -/// The `path` the call names, or `None` when it names none, refused when -/// it is only whitespace. -fn path<'a>(tool: &str, args: &'a Value) -> Result, Error> { - let Some(path) = text(args, "path") else { - return Ok(None); - }; - refuse_blank(tool, "path", path)?; - Ok(Some(path)) -} - -/// The string under `name`, or `None` when the call left it out, passed -/// `null`, or passed an empty string. -fn text<'a>(args: &'a Value, name: &str) -> Option<&'a str> { - args.get(name) - .and_then(Value::as_str) - .filter(|text| !text.is_empty()) -} - -/// The output mode: `content` unless the call names another. -fn mode(args: &Value) -> Mode { - match args.get("output_mode").and_then(Value::as_str) { - Some("files_with_matches") => Mode::FilesWithMatches, - Some("count") => Mode::Count, - _ => Mode::Content, - } -} - -/// The boolean under `name`, `false` when the call left it out. -fn flag(args: &Value, name: &str) -> bool { - args.get(name).and_then(Value::as_bool).unwrap_or(false) -} - -/// Compiles the pattern with the call's flags. -/// -/// Matching is case-sensitive unless `-i` is set, and `^` and `$` match at -/// each line. With `multiline`, `.` also matches a line break and a line -/// break is `\r\n` or `\n`. `literal` matches the pattern as plain text. -fn compile(tool: &str, pattern: &str, args: &Value, multiline: bool) -> Result { - let source = if flag(args, "literal") { - regex::escape(pattern) - } else { - pattern.to_owned() - }; - RegexBuilder::new(&source) - .case_insensitive(flag(args, "-i")) - .multi_line(true) - .dot_matches_new_line(multiline) - .crlf(multiline) - .build() - .map_err(|error| { - // The crate's syntax errors echo the pattern and then end with - // `error: `, so the reason is what follows the last one. - let message = error.to_string(); - let reason = message - .rsplit_once("error: ") - .map_or(message.trim(), |(_, reason)| reason.trim()); - make_invalid_arguments_error( - tool, - format!( - "Invalid regular expression \"{pattern}\": {reason}. Grep matches with Rust \ - regex syntax, which has no look-around and no back-references. To match the \ - text literally, set literal to true." - ), - ) - }) -} diff --git a/crates/plugin-std/src/grep/find.rs b/crates/plugin-std/src/grep/find.rs deleted file mode 100644 index ef84968d6..000000000 --- a/crates/plugin-std/src/grep/find.rs +++ /dev/null @@ -1,388 +0,0 @@ -//! The search: walks the tree, reads each file, and gathers the entries -//! the answer shows. -//! -//! [`find`] is a pure function of its [`Source`]: it reads no clock, keeps -//! nothing between calls, and returns what it found without writing any -//! text. It yields to the executor now and then, so a running search can -//! be canceled. - -mod collect; - -use runvil_plugin::{Entry, FileType, VfsError}; - -use super::args::{Mode, Request}; -use super::found::{Found, Limits, Reason, Stop}; -use super::matching::each_match_line; -use super::pause::Pause; -use super::source::Source; -use super::types; -use crate::order::{GIT, sorted}; -use crate::refusal::{missing_root, neither_file_nor_directory, refusal}; -use crate::text::{decode, sniff_binary, split_lines}; - -/// A path as the file operations are given it, beside the absolute path -/// the answer prints. -/// -/// The two differ only when the model wrote a path with no leading `/`: -/// the operations get it as written, and the answer adds the `/`. -struct Node { - /// The path to pass to the [`Source`]. - access: String, - /// The absolute virtual path to print and to match a glob against. - shown: String, -} - -impl Node { - /// The node for the directory the model named, without a trailing `/`. - fn directory(path: &str) -> Node { - let trimmed = path.trim_end_matches('/'); - let access = if trimmed.is_empty() { - "/".to_owned() - } else { - trimmed.to_owned() - }; - let shown = if access.starts_with('/') { - access.clone() - } else { - format!("/{access}") - }; - Node { access, shown } - } - - /// The node for the file the model named. - fn file(path: &str) -> Node { - let shown = if path.starts_with('/') { - path.to_owned() - } else { - format!("/{path}") - }; - Node { - access: path.to_owned(), - shown, - } - } - - /// The node for the entry `name` of this directory. - fn join(&self, name: &str) -> Node { - let slash = |path: &str| { - if path.ends_with('/') { - format!("{path}{name}") - } else { - format!("{path}/{name}") - } - }; - Node { - access: slash(&self.access), - shown: slash(&self.shown), - } - } -} - -/// A directory the walk is part way through. -struct Frame { - /// The directory. - dir: Node, - /// Its entries not yet visited, in the order they are visited. - entries: std::vec::IntoIter, -} - -/// Searches the tree at the request's path. -/// -/// `tool` is the name the model called the tool by. -/// -/// # Errors -/// Returns the sentence the model reads when the root cannot be searched: -/// it does not exist, it cannot be listed or read, or it is neither a -/// file nor a directory. Nothing below the root fails the search. -pub(super) async fn find( - source: &S, - tool: &str, - request: &Request<'_>, - limits: &Limits, -) -> Result { - let root = request.path.unwrap_or("/"); - let stat = match source.stat(root) { - Ok(stat) => stat, - Err(VfsError::NotFound { .. }) => return Err(missing_root(root)), - Err(error) => return Err(refusal(tool, root, &error)), - }; - let mut search = Search { - source, - tool, - request, - limits, - found: Found::default(), - cost: 0, - considered: 0, - bytes: 0, - since_files: 0, - since_bytes: 0, - finished: false, - }; - match stat.file_type { - FileType::Directory => search.directory(root).await?, - FileType::File => search.root_file(root, Some(stat.size))?, - FileType::Symlink => search.link(root).await?, - _ => return Err(neither_file_nor_directory(root)), - } - Ok(search.found) -} - -/// One search in progress. -struct Search<'a, 'r, S: Source> { - /// Where the tree is read from. - source: &'a S, - /// The name the model called the tool by. - tool: &'a str, - /// What the call asked for. - request: &'a Request<'r>, - /// The limits the search runs under. - limits: &'a Limits, - /// What has been found so far. - found: Found, - /// The bytes the kept lines will take in the answer, counted the way - /// the render counts them. - cost: usize, - /// How many files have been considered. - considered: usize, - /// How many bytes have been read. - bytes: u64, - /// The files considered since the last pause. - since_files: usize, - /// The bytes read since the last pause. - since_bytes: u64, - /// Whether the search has everything it needs, or hit a limit. - finished: bool, -} - -impl Search<'_, '_, S> { - /// Searches the directory the model named. - async fn directory(&mut self, root: &str) -> Result<(), String> { - let node = Node::directory(root); - let entries = self - .source - .list(&node.access) - .map_err(|error| refusal(self.tool, root, &error))?; - self.walk(node, entries).await; - Ok(()) - } - - /// Searches the link the model named: as a directory when it lists, - /// and as a file when it does not. - async fn link(&mut self, root: &str) -> Result<(), String> { - let node = Node::directory(root); - match self.source.list(&node.access) { - Ok(entries) => { - self.walk(node, entries).await; - Ok(()) - } - Err(VfsError::NotADirectory { .. }) => self.root_file(root, None), - Err(error) => Err(refusal(self.tool, root, &error)), - } - } - - /// Searches the one file the model named. No ignore rule, glob, or - /// type applies, because the model named it. - /// - /// `size` is the file's size when the stat gave it, and `None` for a - /// link, whose own size says nothing about its target. - fn root_file(&mut self, root: &str, size: Option) -> Result<(), String> { - let node = Node::file(root); - self.considered += 1; - let bytes = if let Some(size) = size { - if size > self.limits.max_file_size { - self.skip(Reason::OverSize, Some(&node.shown)); - return Ok(()); - } - self.source.read(&node.access) - } else { - self.source - .read_prefix(&node.access, self.limits.max_file_size.saturating_add(1)) - } - .map_err(|error| refusal(self.tool, root, &error))?; - if size.is_none() && length(&bytes) > self.limits.max_file_size { - self.skip(Reason::OverSize, Some(&node.shown)); - return Ok(()); - } - self.bytes += length(&bytes); - self.consume(&node.shown, &bytes); - Ok(()) - } - - /// Walks the directory `root`, whose `entries` are already listed, - /// depth first. - async fn walk(&mut self, root: Node, entries: Vec) { - let mut stack = vec![Frame { - dir: root, - entries: sorted(entries).into_iter(), - }]; - while let Some(frame) = stack.last_mut() { - if self.finished { - return; - } - let Some(entry) = frame.entries.next() else { - stack.pop(); - continue; - }; - if entry.name == GIT { - continue; - } - let child = frame.dir.join(&entry.name); - if entry.name.chars().any(|c| c < ' ' || c == '\u{7f}') { - self.skip(Reason::ControlName, None); - continue; - } - match entry.stat.file_type { - FileType::Symlink => self.skip(Reason::SymbolicLink, Some(&child.shown)), - FileType::Directory => match self.source.list(&child.access) { - Ok(entries) => stack.push(Frame { - dir: child, - entries: sorted(entries).into_iter(), - }), - Err(error) => self.skip(directory_reason(&error), Some(&child.shown)), - }, - // A pipe, a socket, or a device is never opened: reading one - // can block for good, so it is skipped as unreadable. - FileType::File => { - self.visit(&child, &entry.name, entry.stat.size); - self.pace().await; - } - _ => self.skip(Reason::UnreadableFile, Some(&child.shown)), - } - } - } - - /// Visits one file the walk met: counts it, applies the filters, - /// reads it, and searches it. - fn visit(&mut self, node: &Node, name: &str, size: u64) { - if self.considered >= self.limits.max_files { - self.finish(Stop::Files); - return; - } - self.considered += 1; - self.since_files += 1; - if let Some((_, matcher)) = &self.request.glob - && !matcher.is_match(&node.shown) - { - return; - } - if let Some(extensions) = self.request.kind - && !types::matches(extensions, name) - { - return; - } - if size > self.limits.max_file_size { - self.skip(Reason::OverSize, Some(&node.shown)); - return; - } - if self.bytes >= self.limits.max_bytes { - self.finish(Stop::Bytes); - return; - } - match self.source.read(&node.access) { - Ok(bytes) => { - self.bytes += length(&bytes); - self.since_bytes += length(&bytes); - self.consume(&node.shown, &bytes); - } - Err(error) => self.skip(file_reason(&error), Some(&node.shown)), - } - } - - /// Pauses when enough files or bytes have gone by since the last - /// pause, so a cancel or a stop can drop the future. - async fn pace(&mut self) { - if self.since_files >= self.limits.slice_files - || self.since_bytes >= self.limits.slice_bytes - { - self.since_files = 0; - self.since_bytes = 0; - Pause::default().await; - } - } - - /// Counts one skipped entry, and keeps its path for the skip line - /// when it has a printable one. - fn skip(&mut self, reason: Reason, path: Option<&str>) { - self.found.skips.record(reason, path); - } - - /// Ends the search at a limit. - fn finish(&mut self, stop: Stop) { - self.found.stop = Some(stop); - self.finished = true; - } - - /// Searches the bytes of the file at `path`. - fn consume(&mut self, path: &str, bytes: &[u8]) { - if sniff_binary(bytes) { - self.skip(Reason::Binary, Some(path)); - return; - } - let decoded = decode(bytes); - self.found.searched += 1; - if decoded.invalid > 0 { - self.found.replaced.record(path); - } - let text = decoded.text; - let lines = split_lines(&text); - match self.request.mode { - Mode::Content => self.collect_lines(path, &text, &lines), - Mode::FilesWithMatches => { - let mut matched = false; - each_match_line( - &self.request.regex, - self.request.multiline, - &text, - &lines, - |_, _| { - matched = true; - false - }, - ); - if matched { - self.collect_file(path, 0); - } - } - Mode::Count => { - let mut matching = 0; - each_match_line( - &self.request.regex, - self.request.multiline, - &text, - &lines, - |_, _| { - matching += 1; - true - }, - ); - if matching > 0 { - self.collect_file(path, matching); - } - } - } - } -} - -/// The length of `bytes` as a `u64`. -fn length(bytes: &[u8]) -> u64 { - u64::try_from(bytes.len()).unwrap_or(u64::MAX) -} - -/// Why a directory that failed to list is skipped. -fn directory_reason(error: &VfsError) -> Reason { - match error { - VfsError::Conflict { .. } => Reason::Conflict, - VfsError::PermissionDenied { .. } | VfsError::ApprovalRequired { .. } => Reason::Denied, - _ => Reason::UnreadableDirectory, - } -} - -/// Why a file that failed to read is skipped. -fn file_reason(error: &VfsError) -> Reason { - match error { - VfsError::Conflict { .. } => Reason::Conflict, - VfsError::PermissionDenied { .. } | VfsError::ApprovalRequired { .. } => Reason::Denied, - _ => Reason::UnreadableFile, - } -} diff --git a/crates/plugin-std/src/grep/find/collect.rs b/crates/plugin-std/src/grep/find/collect.rs deleted file mode 100644 index 911c3df32..000000000 --- a/crates/plugin-std/src/grep/find/collect.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Gathering the entries the answer shows: which entries are kept, and -//! the lines each one keeps. -//! -//! Entries are counted in walk order across all files. The first `offset` -//! are only counted, the next `head_limit` are kept, and one more is -//! counted to show the head limit was reached. A kept line is stored as -//! the text the answer will show, so no decoded file is held after it is -//! searched. - -use std::collections::BTreeMap; - -use super::Search; -use crate::grep::found::{FileHit, Row}; -use crate::grep::matching::each_match_line; -use crate::grep::render::{GAP_COST, row_cost, summary_cost}; -use crate::grep::source::Source; -use crate::grep::window::{context_text, match_text}; - -/// What to do with the next entry. -#[derive(Debug, PartialEq, Eq)] -enum Admission { - /// It comes before the offset: it is counted and nothing more. - Skip, - /// It is kept. - Keep, - /// It is the one past the head limit, which is counted and not kept. - Extra, -} - -impl Search<'_, '_, S> { - /// Decides what the next entry is: one before the offset that is only - /// counted, one to keep, or the extra one that shows the head limit - /// was reached. - fn admit(&mut self) -> Admission { - let index = self.found.seen; - self.found.seen += 1; - let offset = self.request.offset; - if index < offset { - return Admission::Skip; - } - if self - .request - .limit - .is_some_and(|limit| index - offset >= limit) - { - self.found.extra = true; - self.found.collected += 1; - self.finished = true; - return Admission::Extra; - } - self.found.collected += 1; - Admission::Keep - } - - /// Ends the search when the kept lines already fill the body cap, so - /// the entries past them, which are never shown, are not gathered. - fn check_cap(&mut self) { - if self.cost > self.limits.body_cap { - self.found.capped = true; - self.finished = true; - } - } - - /// Gathers one file's entry for `files_with_matches` or `count`. - pub(super) fn collect_file(&mut self, path: &str, count: usize) { - if self.admit() != Admission::Keep { - return; - } - self.cost += summary_cost(self.request.mode, path, count); - self.found.hits.push(FileHit { - path: path.to_owned(), - count, - rows: BTreeMap::new(), - }); - self.check_cap(); - } - - /// Gathers the matching lines of one file for `content` mode. - pub(super) fn collect_lines(&mut self, path: &str, text: &str, lines: &[&str]) { - let mut hit: Option = None; - let request = self.request; - each_match_line( - &request.regex, - request.multiline, - text, - lines, - |line, first| match self.admit() { - Admission::Skip => true, - Admission::Extra => false, - Admission::Keep => { - let hit = hit.get_or_insert_with(|| self.begin(path)); - self.keep(hit, lines, line, first); - self.check_cap(); - !self.finished - } - }, - ); - if let Some(hit) = hit { - self.found.hits.push(hit); - } - } - - /// Starts the result of the file at `path`, and counts the lines its - /// path and the blank line before it take. - fn begin(&mut self, path: &str) -> FileHit { - self.cost += path.len() + 1 + usize::from(!self.found.hits.is_empty()); - FileHit { - path: path.to_owned(), - count: 0, - rows: BTreeMap::new(), - } - } - - /// Keeps the matching line at index `line` of `lines`, whose first - /// match starts at byte `first`, with the context lines around it. - /// - /// Lines are kept in order, and stop being kept once the cost passes - /// the body cap, because no line past that point is shown. - fn keep(&mut self, hit: &mut FileHit, lines: &[&str], line: usize, first: usize) { - let request = self.request; - let window = self.limits.window; - let from = line.saturating_sub(request.before); - let to = line - .saturating_add(request.after) - .min(lines.len().saturating_sub(1)); - for index in from..=to { - if self.cost > self.limits.body_cap { - break; - } - let number = index + 1; - let is_match = index == line; - if !is_match && hit.rows.contains_key(&number) { - continue; - } - let Some(source) = lines.get(index) else { - break; - }; - let text = if is_match { - match_text(source, first, &window) - } else { - context_text(source, &window) - }; - self.cost += row_cost(number, &text); - if let Some(old) = hit.rows.insert(number, Row { text, is_match }) { - self.cost -= row_cost(number, &old.text); - } else if (request.before > 0 || request.after > 0) - && hit - .rows - .range(..number) - .next_back() - .is_some_and(|(previous, _)| number > previous + 1) - { - self.cost += GAP_COST; - } - } - } -} diff --git a/crates/plugin-std/src/grep/found.rs b/crates/plugin-std/src/grep/found.rs deleted file mode 100644 index e094a1bc9..000000000 --- a/crates/plugin-std/src/grep/found.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! What a search finds and the limits it runs under. -//! -//! [`find`](super::find::find) fills a [`Found`] and writes no text, and -//! [`render`](super::render::render) turns it into the answer. - -use std::collections::BTreeMap; - -use super::window::Window; -use crate::text::{BODY_CAP, MAX_FILE_BYTES}; - -/// The most files one search considers. -pub(super) const MAX_FILES: usize = 100_000; - -/// The most bytes one search reads: 1 GiB. -const MAX_BYTES: u64 = 1_073_741_824; - -/// How many files are considered between two pauses. -const SLICE_FILES: usize = 32; - -/// How many bytes are read between two pauses: 2 MiB. -const SLICE_BYTES: u64 = 2_097_152; - -/// How many skipped paths the skip line names. -const FIRST_KEPT: usize = 5; - -/// The limits a search runs under. Each is a parameter, with the -/// constant as its default, so a test can lower it. -#[derive(Debug, Clone, Copy)] -pub(super) struct Limits { - /// The most files considered before the search stops. - pub(super) max_files: usize, - /// The most bytes read before the search stops. - pub(super) max_bytes: u64, - /// The largest file, by the size its listing gives, that is searched. - pub(super) max_file_size: u64, - /// The files considered between two pauses. - pub(super) slice_files: usize, - /// The bytes read between two pauses. - pub(super) slice_bytes: u64, - /// The most bytes of body in the answer. - pub(super) body_cap: usize, - /// How much of a long line is shown. - pub(super) window: Window, -} - -impl Default for Limits { - fn default() -> Limits { - Limits { - max_files: MAX_FILES, - max_bytes: MAX_BYTES, - max_file_size: MAX_FILE_BYTES, - slice_files: SLICE_FILES, - slice_bytes: SLICE_BYTES, - body_cap: BODY_CAP, - window: Window::default(), - } - } -} - -/// Why an entry was skipped, in the order the skip line names them. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum Reason { - /// A binary file. - Binary, - /// A directory whose listing failed. - UnreadableDirectory, - /// A file that could not be read. - UnreadableFile, - /// A file whose listed size is over the limit. - OverSize, - /// A symbolic link, which is never entered or read. - SymbolicLink, - /// An entry whose name has a control character. - ControlName, - /// An entry another call in this round holds a claim on. - Conflict, - /// An entry the run's policy refuses. - Denied, -} - -impl Reason { - /// Every reason, in the order the skip line names them. - pub(super) const ALL: [Reason; 8] = [ - Reason::Binary, - Reason::UnreadableDirectory, - Reason::UnreadableFile, - Reason::OverSize, - Reason::SymbolicLink, - Reason::ControlName, - Reason::Conflict, - Reason::Denied, - ]; -} - -/// The entries a search skipped, counted by reason. -#[derive(Debug, Default)] -pub(super) struct Skips { - /// How many entries were skipped for each reason in [`Reason::ALL`]. - counts: [usize; 8], - /// The first printable paths skipped, in walk order. - first: Vec, -} - -impl Skips { - /// Counts one skip for `reason`. `path` is the entry's path, or `None` - /// for a name that is never printed. - pub(super) fn record(&mut self, reason: Reason, path: Option<&str>) { - self.counts[reason as usize] += 1; - if let Some(path) = path - && self.first.len() < FIRST_KEPT - { - self.first.push(path.to_owned()); - } - } - - /// How many entries were skipped for `reason`. - pub(super) fn count(&self, reason: Reason) -> usize { - self.counts[reason as usize] - } - - /// How many entries were skipped for any reason. - pub(super) fn total(&self) -> usize { - self.counts.iter().sum() - } - - /// The first printable paths skipped, in walk order. - pub(super) fn first(&self) -> &[String] { - &self.first - } -} - -/// The files whose invalid UTF-8 was shown as U+FFFD. -#[derive(Debug, Default)] -pub(super) struct Replaced { - /// How many searched files needed a replacement. - pub(super) count: usize, - /// The first of their paths, in walk order. - pub(super) first: Vec, -} - -impl Replaced { - /// Notes that the file at `path` needed a replacement. - pub(super) fn record(&mut self, path: &str) { - self.count += 1; - if self.first.len() < FIRST_KEPT { - self.first.push(path.to_owned()); - } - } -} - -/// A limit that ended the search before the tree was done. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum Stop { - /// The search considered the most files it may. - Files, - /// The search read the most bytes it may. - Bytes, -} - -/// One line of a file's result: what is shown after its number. -#[derive(Debug)] -pub(super) struct Row { - /// The text shown, windowed or clipped when the line is long. - pub(super) text: String, - /// Whether the line is an entry. A context line is not. - pub(super) is_match: bool, -} - -/// What one file adds to the result. -#[derive(Debug)] -pub(super) struct FileHit { - /// The file's absolute virtual path. - pub(super) path: String, - /// The file's matching-line count, which `count` mode shows. - pub(super) count: usize, - /// The lines `content` mode shows, by line number from 1, in order. - pub(super) rows: BTreeMap, -} - -/// The outcome of a search that did not fail. -#[derive(Debug, Default)] -pub(super) struct Found { - /// The files that hold a kept entry, in walk order. - pub(super) hits: Vec, - /// The entries gathered after the offset: the ones kept, and the one - /// extra that only shows the head limit was reached. - pub(super) collected: usize, - /// Whether the extra entry was found. - pub(super) extra: bool, - /// Whether the search stopped collecting because the kept lines - /// already fill the body cap. - pub(super) capped: bool, - /// How many entries the search counted, the ones before the offset - /// included. - pub(super) seen: usize, - /// How many files were searched. - pub(super) searched: usize, - /// The entries that were skipped. - pub(super) skips: Skips, - /// The files that needed a replacement. - pub(super) replaced: Replaced, - /// The limit that ended the search early, when one did. - pub(super) stop: Option, -} diff --git a/crates/plugin-std/src/grep/matching.rs b/crates/plugin-std/src/grep/matching.rs deleted file mode 100644 index 2f7febbd3..000000000 --- a/crates/plugin-std/src/grep/matching.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Finding the matching lines of one decoded file. -//! -//! Outside `multiline` a line is a subject of its own. With it the whole -//! text is one subject, and every line a match touches is a matching line, -//! so a line number here is always the number Read prints for that line. - -use regex::Regex; - -/// Calls `emit` for each matching line of `text`, in line order and once -/// per line, until `emit` returns `false`. -/// -/// `lines` is `text` split the way Read splits it. `emit` gets the line's -/// index, from 0, and the byte offset in that line of the first match -/// that covers it: where the first match starts when it starts on this -/// line, and 0 for a line a match enters from an earlier one. -pub(super) fn each_match_line( - regex: &Regex, - multiline: bool, - text: &str, - lines: &[&str], - mut emit: impl FnMut(usize, usize) -> bool, -) { - if !multiline { - for (index, line) in lines.iter().enumerate() { - if let Some(found) = regex.find(line) - && !emit(index, found.start()) - { - return; - } - } - return; - } - let starts = line_starts(text); - // The first line no match has been reported for yet. - let mut next = 0; - for found in regex.find_iter(text) { - let Some(first) = line_of(&starts, text, found.start()) else { - continue; - }; - // An empty match touches only the line it starts on. - let last_byte = found.end().saturating_sub(1).max(found.start()); - let last = line_of(&starts, text, last_byte).unwrap_or(first); - let first_start = starts.get(first).copied().unwrap_or_default(); - for line in first.max(next)..=last { - let offset = if line == first { - found.start() - first_start - } else { - 0 - }; - let width = lines.get(line).map_or(0, |line| line.len()); - if !emit(line, offset.min(width)) { - return; - } - } - next = next.max(last + 1); - } -} - -/// The byte offset where each line of `text` starts, split the way -/// `split_lines` splits: on `\n`, with no line after a final newline. -fn line_starts(text: &str) -> Vec { - let mut starts = Vec::new(); - let mut start = 0; - for piece in text.split_terminator('\n') { - starts.push(start); - start += piece.len() + 1; - } - starts -} - -/// The index of the line that holds byte `offset` of `text`, or `None` -/// when no line does: the text is empty, or `offset` is the end of a text -/// that ends in a newline. -fn line_of(starts: &[usize], text: &str, offset: usize) -> Option { - let after = starts.partition_point(|start| *start <= offset); - let line = after.checked_sub(1)?; - if offset == text.len() && text.ends_with('\n') { - return None; - } - Some(line) -} - -#[cfg(test)] -mod tests; diff --git a/crates/plugin-std/src/grep/matching/tests.rs b/crates/plugin-std/src/grep/matching/tests.rs deleted file mode 100644 index db9a88532..000000000 --- a/crates/plugin-std/src/grep/matching/tests.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Unit tests for the matching-line stream. - -use regex::RegexBuilder; - -use super::{each_match_line, line_of, line_starts}; -use crate::text::split_lines; - -/// The matching lines of `text` for `pattern`, each with its first-match -/// offset, using the flags Grep compiles with. -fn matches(pattern: &str, multiline: bool, text: &str) -> Vec<(usize, usize)> { - let regex = RegexBuilder::new(pattern) - .multi_line(true) - .dot_matches_new_line(multiline) - .crlf(multiline) - .build() - .expect("the pattern compiles"); - let lines = split_lines(text); - let mut found = Vec::new(); - each_match_line(®ex, multiline, text, &lines, |line, offset| { - found.push((line, offset)); - true - }); - found -} - -#[test] -fn line_by_line_reports_each_matching_line_once_with_its_first_match() { - assert_eq!( - matches("a", false, "xa\nb\nana\n"), - [(0, 1), (2, 0)], - "the second match on a line does not repeat the line" - ); -} - -#[test] -fn a_multiline_match_touches_every_line_from_its_first_byte_to_its_last() { - assert_eq!( - matches("b\nc", true, "a\nb\nc\nd\n"), - [(1, 0), (2, 0)], - "lines 2 and 3" - ); - assert_eq!( - matches("b\n", true, "a\nb\nc\n"), - [(1, 0)], - "a match that ends with the newline touches only the line it ends" - ); -} - -#[test] -fn a_line_two_matches_touch_is_reported_once_with_the_first_matchs_offset() { - assert_eq!( - matches("a\nb|c", true, "xa\nbc\n"), - [(0, 1), (1, 0)], - "line 2 holds the end of one match and the whole of another" - ); -} - -#[test] -fn an_empty_match_touches_the_line_it_starts_on_and_none_after_a_final_newline() { - assert_eq!(matches("$", true, "a\nb\n"), [(0, 1), (1, 1)]); - assert_eq!(matches("$", true, "a"), [(0, 1)]); - assert_eq!(matches("^", true, ""), []); -} - -#[test] -fn the_stream_stops_when_the_callback_says_so() { - let regex = RegexBuilder::new("a").build().expect("compiles"); - let text = "a\na\na\n"; - let lines = split_lines(text); - let mut seen = 0; - each_match_line(®ex, false, text, &lines, |_, _| { - seen += 1; - seen < 2 - }); - assert_eq!(seen, 2); -} - -#[test] -fn line_starts_agree_with_split_lines_for_every_shape_of_ending() { - for text in ["", "a", "a\n", "a\n\n", "\n", "a\r\nb\r\n", "a\n\nb"] { - assert_eq!(line_starts(text).len(), split_lines(text).len(), "{text:?}"); - } -} - -#[test] -fn line_of_places_offsets_on_the_line_that_holds_them() { - let text = "a\n\nb"; - let starts = line_starts(text); - assert_eq!(starts, [0, 2, 3]); - assert_eq!(line_of(&starts, text, 0), Some(0)); - assert_eq!(line_of(&starts, text, 1), Some(0)); - assert_eq!(line_of(&starts, text, 2), Some(1)); - assert_eq!(line_of(&starts, text, 4), Some(2)); - assert_eq!(line_of(&line_starts("a\n"), "a\n", 2), None); -} diff --git a/crates/plugin-std/src/grep/pause.rs b/crates/plugin-std/src/grep/pause.rs deleted file mode 100644 index 7c07e4614..000000000 --- a/crates/plugin-std/src/grep/pause.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! The pause that lets a running search be canceled. -//! -//! The Harness polls a call's future inside its effect loop, and a cancel -//! or a stop drops that future. A future that never returns `Pending` -//! cannot be dropped until it finishes, so Grep awaits a [`Pause`] every -//! so often. The pause wakes its own task and returns `Pending` once, -//! which hands control back to the Host's executor, and returns `Ready` -//! on the next poll. It names no async runtime. - -use std::future::Future; -use std::pin::Pin; -use std::task::{Context, Poll}; - -/// A future that is `Pending` for one poll and then `Ready`. -#[derive(Debug, Default)] -pub(super) struct Pause { - /// Whether the one `Pending` has been returned. - yielded: bool, -} - -impl Future for Pause { - type Output = (); - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> { - if self.yielded { - return Poll::Ready(()); - } - self.yielded = true; - cx.waker().wake_by_ref(); - Poll::Pending - } -} diff --git a/crates/plugin-std/src/grep/render.rs b/crates/plugin-std/src/grep/render.rs deleted file mode 100644 index dfa13fc35..000000000 --- a/crates/plugin-std/src/grep/render.rs +++ /dev/null @@ -1,380 +0,0 @@ -//! The render: turns what a search found into the text Grep answers with. -//! -//! There is no envelope. The answer is the body, and then, when there is -//! anything to add, a blank line and the extra lines: the replacement -//! note, the skip line, and the paging or limit notice. The extra lines -//! never count against the body cap. - -use runvil_plugin::ToolOutput; - -use super::args::{Mode, Request}; -use super::found::{Found, Limits, Reason, Stop}; -use crate::check::plural; - -/// The line printed between two groups of one file that are not next to -/// each other, when context is on. -const GAP: &str = " --"; - -/// What a gap line costs against the cap, with its newline. -pub(super) const GAP_COST: usize = GAP.len() + 1; - -/// The line that tells a model nothing was filtered by ignore rules. -const IGNORE_NOTE: &str = - "[No ignore rules ran: .gitignore is not applied, and only entries named .git are skipped.]"; - -/// The line that says what `{`, `[`, and `?` do in a glob. -const GLOB_HINT: &str = "{, [ and ? in glob match themselves; there is no alternation or \ - character class. Call Grep once per alternative, or filter with type."; - -/// What the body cap costs a line: its text and its newline. -fn line_cost(text_len: usize) -> usize { - text_len + 1 -} - -/// What a `content` line costs against the cap: two spaces, the number, -/// the `:` or `-`, the text, and the newline. -pub(super) fn row_cost(number: usize, text: &str) -> usize { - line_cost(2 + number.to_string().len() + 1 + text.len()) -} - -/// What a `files_with_matches` or `count` line costs against the cap. -pub(super) fn summary_cost(mode: Mode, path: &str, count: usize) -> usize { - match mode { - Mode::Count => line_cost(path.len() + 1 + count.to_string().len()), - _ => line_cost(path.len()), - } -} - -/// What kind of line a row is. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Kind { - /// A file's path, which opens its group in `content` mode. - Path, - /// The empty line between two files. - Blank, - /// The gap line. - Gap, - /// A matching line. - Match, - /// A context line. - Context, - /// A path, or a path and its count, in the other two modes. - Summary, -} - -/// One line of the body. -#[derive(Debug)] -struct Out { - /// The line, without its newline. - text: String, - /// What the line is. - kind: Kind, - /// The line number of a matching or context line, and 0 for others. - line: usize, -} - -/// The body: the lines that fit under the cap. -#[derive(Debug)] -struct Body { - /// The lines joined with `\n`. - text: String, - /// How many entries the lines show. - shown: usize, - /// Whether the cap cut the body short. - cut: bool, -} - -/// The answer to a search that found `found`. -pub(super) fn render(found: &Found, request: &Request<'_>, limits: &Limits) -> ToolOutput { - let note = replacement_note(found); - let skip = skip_line(found, limits); - let stopped = found.stop.map(|stop| limit_notice(stop, limits)); - if found.seen == 0 { - let text = format!( - "No matches found. Searched {}.", - plural(found.searched, "file") - ); - let trusted = note.is_none() && skip.is_none(); - let mut extras: Vec = [note, skip].into_iter().flatten().collect(); - extras.push(IGNORE_NOTE.to_owned()); - if request - .glob - .as_ref() - .is_some_and(|(glob, _)| glob.contains(['{', '[', '?'])) - { - extras.push(GLOB_HINT.to_owned()); - } - extras.extend(stopped); - let text = assemble(text, &extras); - return if trusted { - ToolOutput::trusted(text) - } else { - ToolOutput::untrusted(text) - }; - } - if found.collected == 0 { - let extras: Vec = stopped.into_iter().collect(); - let text = format!("No more results at offset={}.", request.offset); - return ToolOutput::untrusted(assemble(text, &extras)); - } - let body = body(found, request, limits.body_cap); - let capped = body.cut || found.capped; - let text = if body.shown == 0 { - format!( - "[Output stopped at {} bytes before the first result fit. Narrow the search with \ - path, glob or type.]", - limits.body_cap - ) - } else { - body.text - }; - let tail = if body.shown == 0 { - stopped - } else { - stopped.or_else(|| paging_notice(found, request, body.shown, capped, limits)) - }; - let extras: Vec = [note, skip, tail].into_iter().flatten().collect(); - ToolOutput::untrusted(assemble(text, &extras)) -} - -/// `body`, then a blank line and the `extras` on their own lines, when -/// there are any. -fn assemble(mut body: String, extras: &[String]) -> String { - if !extras.is_empty() { - body.push_str("\n\n"); - body.push_str(&extras.join("\n")); - } - body -} - -/// The body for `found`, with at most `cap` bytes of lines. -fn body(found: &Found, request: &Request<'_>, cap: usize) -> Body { - let rows = match request.mode { - Mode::Content => content_rows(found, request.before > 0 || request.after > 0), - Mode::FilesWithMatches => found - .hits - .iter() - .map(|hit| Out { - text: hit.path.clone(), - kind: Kind::Summary, - line: 0, - }) - .collect(), - Mode::Count => found - .hits - .iter() - .map(|hit| Out { - text: format!("{}:{}", hit.path, hit.count), - kind: Kind::Summary, - line: 0, - }) - .collect(), - }; - cut(rows, cap, request) -} - -/// The `content` lines of every file that kept one: its path, then its -/// lines in order, with a gap line between two groups that are not next -/// to each other when `gaps` is on, and a blank line between files. -fn content_rows(found: &Found, gaps: bool) -> Vec { - let mut rows = Vec::new(); - for hit in found.hits.iter().filter(|hit| !hit.rows.is_empty()) { - if !rows.is_empty() { - rows.push(Out { - text: String::new(), - kind: Kind::Blank, - line: 0, - }); - } - rows.push(Out { - text: hit.path.clone(), - kind: Kind::Path, - line: 0, - }); - let mut previous: Option = None; - for (number, row) in &hit.rows { - if gaps && previous.is_some_and(|previous| *number > previous + 1) { - rows.push(Out { - text: GAP.to_owned(), - kind: Kind::Gap, - line: 0, - }); - } - previous = Some(*number); - let mark = if row.is_match { ':' } else { '-' }; - rows.push(Out { - text: format!(" {number}{mark}{}", row.text), - kind: if row.is_match { - Kind::Match - } else { - Kind::Context - }, - line: *number, - }); - } - } - rows -} - -/// `rows` cut before the line that would pass `cap`. -/// -/// A cut that falls inside a file's lines leaves nothing dangling: the -/// body ends at the last matching line it shows, or at the last of that -/// line's own trailing context lines, never at a path, a gap, or the -/// leading context of a match that did not fit. -fn cut(mut rows: Vec, cap: usize, request: &Request<'_>) -> Body { - let mut used = 0; - let mut keep = rows.len(); - for (index, row) in rows.iter().enumerate() { - let cost = line_cost(row.text.len()); - if used + cost > cap { - keep = index; - break; - } - used += cost; - } - let cut = keep < rows.len(); - rows.truncate(keep); - if cut && request.mode == Mode::Content { - let end = rows - .iter() - .rposition(|row| row.kind == Kind::Match) - .map_or(0, |last| { - let limit = rows[last].line.saturating_add(request.after); - let trailing = rows[last + 1..] - .iter() - .take_while(|row| row.kind == Kind::Context && row.line <= limit) - .count(); - last + 1 + trailing - }); - rows.truncate(end); - } - let shown = rows - .iter() - .filter(|row| matches!(row.kind, Kind::Match | Kind::Summary)) - .count(); - let text = rows - .iter() - .map(|row| row.text.as_str()) - .collect::>() - .join("\n"); - Body { text, shown, cut } -} - -/// The paging notice, or `None` when there is nothing to say. -/// -/// There is no cache, so each call runs from the start. A cut says where -/// to continue. A page after the first that reaches the end gives an -/// exact total. A first page that nothing cut gets no notice. -fn paging_notice( - found: &Found, - request: &Request<'_>, - shown: usize, - capped: bool, - limits: &Limits, -) -> Option { - let offset = request.offset; - let unit = if request.mode == Mode::Content { - "matching lines" - } else { - "files" - }; - let cut = capped || found.extra; - if !cut && offset == 0 { - return None; - } - let first = offset + 1; - let last = offset + shown; - let total = offset + found.collected; - if !cut { - return Some(format!("[Showing {first}-{last} of {total} {unit}.]")); - } - let stopped = if capped { - format!(" Output stopped at {} bytes.", limits.body_cap) - } else { - String::new() - }; - Some(format!( - "[Showing {first}-{last} of at least {total} {unit}.{stopped} Use offset={last} to see \ - more, or narrow the search.]" - )) -} - -/// The note that names the files whose invalid UTF-8 was replaced. -fn replacement_note(found: &Found) -> Option { - let replaced = &found.replaced; - if replaced.count == 0 { - return None; - } - let mut paths = replaced.first.join(", "); - if replaced.count > replaced.first.len() { - paths = format!( - "{paths}, and {} more", - replaced.count - replaced.first.len() - ); - } - Some(format!( - "[Note: invalid UTF-8 in {} was shown as U+FFFD: {paths}]", - plural(replaced.count, "file") - )) -} - -/// The line that counts the skipped entries by reason and names the first -/// few of them. -fn skip_line(found: &Found, limits: &Limits) -> Option { - let skips = &found.skips; - let total = skips.total(); - if total == 0 { - return None; - } - let phrases: Vec = Reason::ALL - .iter() - .filter_map(|reason| { - let n = skips.count(*reason); - (n > 0).then(|| phrase(*reason, n, limits)) - }) - .collect(); - let first = if skips.first().is_empty() { - String::new() - } else { - format!(" First: {}", skips.first().join(", ")) - }; - Some(format!( - "[Skipped {}: {}.{first}]", - plural(total, "entry"), - phrases.join(", ") - )) -} - -/// The phrase that says `n` entries were skipped for `reason`. -fn phrase(reason: Reason, n: usize, limits: &Limits) -> String { - match reason { - Reason::Binary => plural(n, "binary file"), - Reason::UnreadableDirectory => { - format!("{} that could not be read", plural(n, "directory")) - } - Reason::UnreadableFile => format!("{} that could not be read", plural(n, "file")), - Reason::OverSize => format!("{} over {} bytes", plural(n, "file"), limits.max_file_size), - Reason::SymbolicLink => plural(n, "symbolic link"), - Reason::ControlName => format!("{} with a control character", plural(n, "name")), - Reason::Conflict => format!("{} held by another call in this round", plural(n, "entry")), - Reason::Denied => format!("{} denied by policy", plural(n, "entry")), - } -} - -/// The notice for a search a limit ended early. It offers no `offset`, -/// because a re-run stops at the same place. -fn limit_notice(stop: Stop, limits: &Limits) -> String { - match stop { - Stop::Files => format!( - "[Search stopped after considering {} files. Narrow path, glob or type to search the \ - rest.]", - limits.max_files - ), - Stop::Bytes => format!( - "[Search stopped after reading {} bytes. Narrow path, glob or type to search the \ - rest.]", - limits.max_bytes - ), - } -} diff --git a/crates/plugin-std/src/grep/source.rs b/crates/plugin-std/src/grep/source.rs deleted file mode 100644 index 7cfd6c57f..000000000 --- a/crates/plugin-std/src/grep/source.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! The file operations Grep needs, behind a trait. -//! -//! The call's [`Access`] implements it, so every operation Grep performs -//! goes through the run's mode and policy. The seam exists so that a test -//! can supply what the in-memory backend cannot produce: a symbolic link, -//! a name with a control character, an over-size entry, a denied read, and -//! a claim conflict. - -use runvil_plugin::{Access, Entry, Stat, VfsError}; - -/// Where Grep reads the tree from. -pub(super) trait Source { - /// Metadata for `path`, without following a link. - fn stat(&self, path: &str) -> Result; - - /// The entries of the directory at `path`, in no promised order. - fn list(&self, path: &str) -> Result, VfsError>; - - /// The whole contents of the file at `path`. - fn read(&self, path: &str) -> Result, VfsError>; - - /// At most the first `len` bytes of the file at `path`, for a path - /// whose size is not known beforehand. - fn read_prefix(&self, path: &str, len: u64) -> Result, VfsError>; -} - -impl Source for Access { - fn stat(&self, path: &str) -> Result { - Access::stat(self, path) - } - - fn list(&self, path: &str) -> Result, VfsError> { - Access::list(self, path) - } - - fn read(&self, path: &str) -> Result, VfsError> { - Access::read(self, path) - } - - fn read_prefix(&self, path: &str, len: u64) -> Result, VfsError> { - Access::read_range(self, path, 0, len) - } -} diff --git a/crates/plugin-std/src/grep/tests.rs b/crates/plugin-std/src/grep/tests.rs deleted file mode 100644 index 826706dfb..000000000 --- a/crates/plugin-std/src/grep/tests.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! Unit tests for the `Grep` tool. This file holds the helpers the topic -//! files share: a call over a fresh in-memory filesystem, a seeded tree, -//! and the ways to drive Grep, through its arguments and through lowered -//! limits over any [`Source`]. - -mod arguments; -mod fake; -mod filters; -mod paging; -mod patterns; -mod render; -mod search; -mod skips; -mod text; -mod window; - -use std::future::Future; -use std::pin::pin; -use std::task::{Context, Poll, Waker}; - -use runvil_plugin::testing::TestCall; -use runvil_plugin::{Error, OutputTrust, ToolId, ToolOutput}; -use serde_json::Value; - -use super::args; -use super::found::Limits; -use super::source::Source; -use crate::spec::{self, Definition}; - -/// Drives a future to its output, polling again after each `Pending`. -fn block_on(future: F) -> F::Output { - let mut future = pin!(future); - let mut cx = Context::from_waker(Waker::noop()); - loop { - if let Poll::Ready(output) = future.as_mut().poll(&mut cx) { - return output; - } - } -} - -/// Drives a future to its output and counts how many times it returned -/// `Pending` on the way. -fn count_pendings(future: F) -> (F::Output, usize) { - let mut future = pin!(future); - let mut cx = Context::from_waker(Waker::noop()); - let mut pending = 0; - loop { - if let Poll::Ready(output) = future.as_mut().poll(&mut cx) { - return (output, pending); - } - pending += 1; - } -} - -/// A call of `std/grep` over a fresh in-memory filesystem. -fn new_call() -> TestCall { - TestCall::new(ToolId::parse("std/grep").expect("a two-segment tool id parses")) -} - -/// A call with one file at each of `files`, a path beside its text, which -/// makes every directory on the way to it. -fn seeded(files: &[(&str, &str)]) -> TestCall { - let call = new_call(); - for (path, text) in files { - call.context() - .access() - .write(path, text.as_bytes()) - .expect("a seeded file is written"); - } - call -} - -/// The `grep` definition the Pack builds the tool from. -fn definition() -> &'static Definition { - spec::all() - .expect("every embedded definition parses") - .iter() - .find(|definition| definition.segment == "grep") - .expect("the grep definition exists") -} - -/// Calls Grep with `args` as the Pack does: the schema check first, then -/// the tool. -fn grep(call: &TestCall, args: &Value) -> Result { - let definition = definition(); - crate::check::check(&definition.wire_name, &definition.parameters, args)?; - block_on(super::call(call.context().access(), definition, args)) -} - -/// Calls Grep over `source` with `args`, under `limits`. -fn grep_over(source: &impl Source, args: &Value, limits: &Limits) -> Result { - let definition = definition(); - crate::check::check(&definition.wire_name, &definition.parameters, args)?; - let request = args::parse(definition, args)?; - block_on(super::search(source, "Grep", &request, limits)) -} - -/// Calls Grep over the call's filesystem under `limits`. -fn grep_limited(call: &TestCall, args: &Value, limits: &Limits) -> Result { - grep_over(call.context().access(), args, limits) -} - -/// The text of a successful search, which must be untrusted output. -fn text_of(result: Result) -> String { - let output = result.expect("the search succeeds"); - assert_eq!(output.trust(), OutputTrust::Untrusted, "{}", output.text()); - output.text().to_owned() -} - -/// The message of a failed search. -fn failure_of(result: Result) -> String { - match result { - Ok(output) => panic!("the search fails, but it answered {:?}", output.text()), - Err(error) => error.to_string(), - } -} diff --git a/crates/plugin-std/src/grep/tests/arguments.rs b/crates/plugin-std/src/grep/tests/arguments.rs deleted file mode 100644 index 8d0ea87f7..000000000 --- a/crates/plugin-std/src/grep/tests/arguments.rs +++ /dev/null @@ -1,246 +0,0 @@ -//! What Grep refuses about its arguments, and in which order. - -use runvil_plugin::ErrorCode; -use serde_json::{Value, json}; - -use super::{definition, failure_of, grep, new_call, seeded}; -use crate::grep::args::{self, Mode}; - -/// The thirteen parameters, sorted bytewise, as an undeclared-key refusal -/// lists them. -const PARAMETERS: &str = "`-A`, `-B`, `-C`, `-i`, `glob`, `head_limit`, `literal`, `multiline`, \ - `offset`, `output_mode`, `path`, `pattern` and `type`"; - -/// The refusal text for a call with `args`. -fn refusal(args: &Value) -> String { - failure_of(grep(&new_call(), args)) -} - -/// The request `args` parse to. -fn parsed(args: &Value) -> args::Request<'_> { - args::parse(definition(), args).expect("the arguments parse") -} - -#[test] -fn a_missing_pattern_names_the_parameter() { - let error = grep(&new_call(), &json!({})).expect_err("pattern is required"); - assert_eq!( - error.to_string(), - "std_Grep: `pattern` is required, and the call left it out" - ); - assert_eq!(error.code, ErrorCode::ToolInvalidArguments); -} - -#[test] -fn an_undeclared_key_is_refused_naming_it_and_listing_the_sorted_parameters() { - let error = grep(&new_call(), &json!({ "pattern": "x", "context": 3 })) - .expect_err("context is not a parameter"); - assert_eq!( - error.to_string(), - format!( - "std_Grep: `context` is not a parameter of this tool. The parameters are {PARAMETERS}." - ) - ); - assert_eq!(error.code, ErrorCode::ToolInvalidArguments); -} - -#[test] -fn the_one_undeclared_key_that_is_accepted_is_n_when_it_is_true() { - let call = seeded(&[("/a.txt", "needle\n")]); - for accepted in [json!(true), Value::Null] { - let output = grep(&call, &json!({ "pattern": "needle", "-n": accepted })) - .expect("-n true or null is accepted"); - assert_eq!(output.text(), "/a.txt\n 1:needle"); - } - let off = "std_Grep: `-n` must be true or left out, because line numbers are always shown."; - for refused in [json!(false), json!("yes"), json!(1)] { - assert_eq!(refusal(&json!({ "pattern": "x", "-n": refused })), off); - } -} - -#[test] -fn an_empty_pattern_is_refused_with_the_advice_to_pass_a_dot() { - assert_eq!( - refusal(&json!({ "pattern": "" })), - "std_Grep: `pattern` is empty. Pass \".\" to match every line." - ); -} - -#[test] -fn a_negative_context_is_refused_naming_the_key_and_the_value_in_a_fixed_order() { - assert_eq!( - refusal(&json!({ "pattern": "x", "-A": -1 })), - "std_Grep: `-A` is -1. Pass 0 or more context lines." - ); - assert_eq!( - refusal(&json!({ "pattern": "x", "-C": -2, "-B": -3, "-A": -4 })), - "std_Grep: `-A` is -4. Pass 0 or more context lines.", - "-A comes first" - ); - assert_eq!( - refusal(&json!({ "pattern": "x", "-C": -2, "-B": -3 })), - "std_Grep: `-B` is -3. Pass 0 or more context lines.", - "-B comes before -C" - ); - assert_eq!( - refusal(&json!({ "pattern": "x", "-C": -1.5 })), - "std_Grep: `-C` is -1.5. Pass 0 or more context lines." - ); -} - -#[test] -fn a_negative_head_limit_or_offset_is_refused_head_limit_first() { - assert_eq!( - refusal(&json!({ "pattern": "x", "head_limit": -5 })), - "std_Grep: `head_limit` is -5. Pass 0 or more." - ); - assert_eq!( - refusal(&json!({ "pattern": "x", "offset": -1, "head_limit": -5 })), - "std_Grep: `head_limit` is -5. Pass 0 or more." - ); - assert_eq!( - refusal(&json!({ "pattern": "x", "offset": -1 })), - "std_Grep: `offset` is -1. Pass 0 or more." - ); -} - -#[test] -fn an_unknown_type_is_refused_with_the_types_to_try() { - assert_eq!( - refusal(&json!({ "pattern": "x", "type": "foo" })), - "std_Grep: Unrecognized file type \"foo\". Use a type such as js, ts, py, rust, go or \ - java, or filter with glob." - ); -} - -#[test] -fn an_invalid_glob_is_refused_with_the_rule_it_broke() { - assert_eq!( - refusal(&json!({ "pattern": "x", "glob": "a***" })), - "std_Grep: `glob` is invalid: \"a***\" does not parse: more than two consecutive '*' \ - are not supported." - ); -} - -#[test] -fn a_whitespace_only_path_is_refused_echoing_it() { - assert_eq!( - refusal(&json!({ "pattern": "x", "path": " " })), - "std_Grep: `path` is empty or only whitespace, and the call passed \" \"" - ); -} - -#[test] -fn a_call_that_breaks_several_rules_always_hears_about_the_first_in_order() { - let mut args = json!({ - "pattern": "", - "context": 1, - "-n": false, - "-A": -1, - "head_limit": -1, - "type": "foo", - "glob": "a***", - "path": " ", - }); - let order = [ - ("context", "`context` is not a parameter"), - ("-n", "`-n` must be true"), - ("pattern", "`pattern` is empty"), - ("-A", "`-A` is -1"), - ("head_limit", "`head_limit` is -1"), - ("type", "Unrecognized file type"), - ("glob", "`glob` is invalid"), - ("path", "`path` is empty or only whitespace"), - ]; - for (key, expected) in order { - let message = refusal(&args); - assert!( - message.contains(expected), - "expected {expected:?} before {key} is fixed: {message}" - ); - match key { - "pattern" => args["pattern"] = json!("("), - other => { - args.as_object_mut().expect("an object").remove(other); - } - } - } - assert!( - refusal(&args).contains("Invalid regular expression \"(\""), - "the pattern is compiled after every other check" - ); -} - -#[test] -fn an_empty_path_glob_or_type_reads_as_absent() { - let call = seeded(&[("/src/a.rs", "needle\n"), ("/b.txt", "needle\n")]); - let output = grep( - &call, - &json!({ - "pattern": "needle", - "output_mode": "files_with_matches", - "path": "", - "glob": "", - "type": "", - }), - ) - .expect("empty strings read as absent"); - assert_eq!(output.text(), "/b.txt\n/src/a.rs"); -} - -#[test] -fn head_limit_and_context_round_up_and_offset_rounds_down() { - let args = json!({ "pattern": "x", "head_limit": 2.2, "offset": 2.9, "-A": 0.2 }); - let request = parsed(&args); - assert_eq!(request.limit, Some(3)); - assert_eq!(request.offset, 2); - assert_eq!(request.after, 1); -} - -#[test] -fn head_limit_zero_and_an_absent_head_limit_both_mean_no_limit() { - for args in [ - json!({ "pattern": "x", "head_limit": 0 }), - json!({ "pattern": "x" }), - json!({ "pattern": "x", "head_limit": null }), - ] { - assert_eq!(parsed(&args).limit, None, "{args}"); - } -} - -#[test] -fn context_c_sets_both_sides_and_a_and_b_override_it_even_at_zero() { - let sides = |args: Value| { - let request = parsed(&args); - (request.before, request.after) - }; - assert_eq!(sides(json!({ "pattern": "x", "-C": 2 })), (2, 2)); - assert_eq!(sides(json!({ "pattern": "x", "-C": 2, "-A": 0 })), (2, 0)); - assert_eq!(sides(json!({ "pattern": "x", "-C": 2, "-B": 5 })), (5, 2)); - assert_eq!( - sides(json!({ "pattern": "x", "-C": 2, "-B": 0, "-A": 1 })), - (0, 1) - ); - assert_eq!(sides(json!({ "pattern": "x", "-A": 3 })), (0, 3)); -} - -#[test] -fn context_is_ignored_outside_content_mode() { - for mode in ["files_with_matches", "count"] { - let args = json!({ "pattern": "x", "output_mode": mode, "-C": 4 }); - let request = parsed(&args); - assert_eq!((request.before, request.after), (0, 0), "{mode}"); - } - let args = json!({ "pattern": "x", "-C": 4 }); - let request = parsed(&args); - assert_eq!(request.mode, Mode::Content, "content is the default mode"); - assert_eq!((request.before, request.after), (4, 4)); -} - -#[test] -fn a_value_past_the_range_of_usize_saturates_instead_of_failing() { - let args = json!({ "pattern": "x", "offset": 1e30, "-C": 1e30 }); - let request = parsed(&args); - assert_eq!(request.offset, usize::MAX); - assert_eq!(request.before, usize::MAX); -} diff --git a/crates/plugin-std/src/grep/tests/fake.rs b/crates/plugin-std/src/grep/tests/fake.rs deleted file mode 100644 index 60e805121..000000000 --- a/crates/plugin-std/src/grep/tests/fake.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! A [`Source`] the tests build by hand, for what the in-memory backend -//! cannot produce: a link, a control-character name, an over-size entry, -//! a denied read, a claim conflict, and an I/O failure. - -use std::collections::BTreeMap; - -use runvil_plugin::{Entry, FileType, Stat, VfsError}; - -use crate::grep::source::Source; - -/// One node of the fake tree. -#[derive(Debug, Clone)] -pub(super) enum Node { - /// A file with these bytes. - File(Vec), - /// A symbolic link to a directory. - Link, - /// A symbolic link to a file with these bytes. - LinkToFile(Vec), - /// A pipe. - Pipe, - /// A file whose listing says it holds this many bytes, and whose - /// bytes are the second field. - Sized(u64, Vec), - /// A directory whose listing fails with this error. - ListFails(VfsError), - /// A file whose read fails with this error. - ReadFails(VfsError), -} - -/// A tree of nodes by absolute path, with `/` always a directory. -#[derive(Debug, Default)] -pub(super) struct FakeSource { - /// Every node but the root. - nodes: BTreeMap, -} - -impl FakeSource { - /// The tree with `node` added at `path`. - pub(super) fn with(mut self, path: &str, node: Node) -> FakeSource { - self.nodes.insert(path.to_owned(), node); - self - } - - /// The tree with the text file `text` at `path`. - pub(super) fn file(self, path: &str, text: &str) -> FakeSource { - self.with(path, Node::File(text.as_bytes().to_vec())) - } - - /// The metadata of `node`. - fn stat_of(node: &Node) -> Stat { - match node { - Node::File(bytes) => Stat::new(FileType::File, bytes.len() as u64), - Node::Sized(size, _) => Stat::new(FileType::File, *size), - Node::ReadFails(_) => Stat::new(FileType::File, 1), - Node::ListFails(_) => Stat::new(FileType::Directory, 0), - Node::Link | Node::LinkToFile(_) => Stat::new(FileType::Symlink, 0), - Node::Pipe => Stat::new(FileType::Fifo, 0), - } - } - - /// The error for a path that is not there. - fn missing(path: &str) -> VfsError { - VfsError::NotFound { - path: path.to_owned(), - } - } -} - -/// The directory that holds `path`, and the name it has there. -fn split(path: &str) -> (&str, &str) { - match path.rsplit_once('/') { - Some(("", name)) => ("/", name), - Some((parent, name)) => (parent, name), - None => ("/", path), - } -} - -impl Source for FakeSource { - fn stat(&self, path: &str) -> Result { - if path == "/" { - return Ok(Stat::new(FileType::Directory, 0)); - } - self.nodes - .get(path) - .map(Self::stat_of) - .ok_or_else(|| Self::missing(path)) - } - - fn list(&self, path: &str) -> Result, VfsError> { - if path != "/" { - match self.nodes.get(path) { - None => return Err(Self::missing(path)), - Some(Node::ListFails(error)) => return Err(error.clone()), - Some(Node::Link) => {} - Some(_) => { - return Err(VfsError::NotADirectory { - path: path.to_owned(), - }); - } - } - } - Ok(self - .nodes - .iter() - .filter(|(child, _)| split(child).0 == path) - .map(|(child, node)| Entry::new(split(child).1, Self::stat_of(node))) - .collect()) - } - - fn read(&self, path: &str) -> Result, VfsError> { - match self.nodes.get(path) { - Some(Node::File(bytes) | Node::Sized(_, bytes) | Node::LinkToFile(bytes)) => { - Ok(bytes.clone()) - } - Some(Node::ReadFails(error)) => Err(error.clone()), - Some(Node::Link | Node::ListFails(_)) => Err(VfsError::IsADirectory { - path: path.to_owned(), - }), - Some(Node::Pipe) | None => Err(Self::missing(path)), - } - } - - fn read_prefix(&self, path: &str, len: u64) -> Result, VfsError> { - let mut bytes = self.read(path)?; - bytes.truncate(usize::try_from(len).unwrap_or(usize::MAX)); - Ok(bytes) - } -} diff --git a/crates/plugin-std/src/grep/tests/filters.rs b/crates/plugin-std/src/grep/tests/filters.rs deleted file mode 100644 index 1d9a35310..000000000 --- a/crates/plugin-std/src/grep/tests/filters.rs +++ /dev/null @@ -1,125 +0,0 @@ -//! The `glob` and `type` filters, and the one case where neither applies. - -use serde_json::json; - -use super::{grep, seeded, text_of}; - -/// The paths of the files with a match for `x` under `args`. -fn files(call: &runvil_plugin::testing::TestCall, mut args: serde_json::Value) -> String { - args["pattern"] = json!("x"); - args["output_mode"] = json!("files_with_matches"); - text_of(grep(call, &args)) -} - -#[test] -fn a_glob_without_a_leading_slash_matches_at_any_depth() { - let call = seeded(&[("/a.js", "x\n"), ("/d/e/b.js", "x\n"), ("/c.ts", "x\n")]); - assert_eq!(files(&call, json!({ "glob": "*.js" })), "/a.js\n/d/e/b.js"); -} - -#[test] -fn a_glob_with_a_leading_slash_is_a_full_path() { - let call = seeded(&[ - ("/src/a.rs", "x\n"), - ("/src/deep/b.rs", "x\n"), - ("/other/c.rs", "x\n"), - ("/src/d.txt", "x\n"), - ]); - assert_eq!( - files(&call, json!({ "glob": "/src/**/*.rs" })), - "/src/a.rs\n/src/deep/b.rs" - ); -} - -#[test] -fn a_type_keeps_the_files_with_its_extensions() { - let call = seeded(&[ - ("/lib.rs", "x\n"), - ("/main.RS", "x\n"), - ("/notes.md", "x\n"), - ("/sub/mod.rs", "x\n"), - ]); - assert_eq!( - files(&call, json!({ "type": "rust" })), - "/lib.rs\n/sub/mod.rs" - ); -} - -#[test] -fn a_glob_and_a_type_together_keep_only_what_passes_both() { - let call = seeded(&[("/a/x.rs", "x\n"), ("/b/x.rs", "x\n"), ("/a/y.txt", "x\n")]); - assert_eq!( - files(&call, json!({ "glob": "/a/**", "type": "rust" })), - "/a/x.rs" - ); -} - -#[test] -fn a_named_single_file_ignores_both_filters() { - let call = seeded(&[("/c.ts", "x\n")]); - let output = files( - &call, - json!({ "path": "/c.ts", "glob": "*.js", "type": "rust" }), - ); - assert_eq!(output, "/c.ts"); -} - -#[test] -fn a_file_a_filter_drops_is_neither_searched_nor_a_skip() { - let call = seeded(&[("/a.js", "x\n"), ("/b.bin.ts", "x\n")]); - let output = - grep(&call, &json!({ "pattern": "zzz", "glob": "*.js" })).expect("the search succeeds"); - assert!( - output - .text() - .starts_with("No matches found. Searched 1 file."), - "{}", - output.text() - ); - assert!(!output.text().contains("Skipped"), "{}", output.text()); -} - -#[test] -fn a_zero_match_search_with_a_brace_glob_adds_the_hint_line() { - let call = seeded(&[("/a.ts", "x\n")]); - let output = - grep(&call, &json!({ "pattern": "x", "glob": "*.{ts,tsx}" })).expect("the search succeeds"); - assert_eq!( - output.text(), - "No matches found. Searched 0 files.\n\n\ - [No ignore rules ran: .gitignore is not applied, and only entries named .git are \ - skipped.]\n\ - {, [ and ? in glob match themselves; there is no alternation or character class. Call \ - Grep once per alternative, or filter with type." - ); -} - -#[test] -fn a_bracket_or_a_question_mark_in_the_glob_also_adds_the_hint_and_a_plain_glob_does_not() { - let call = seeded(&[("/a.ts", "x\n")]); - for glob in ["*.[jt]s", "*.t?"] { - let output = - grep(&call, &json!({ "pattern": "x", "glob": glob })).expect("the search succeeds"); - assert!( - output - .text() - .contains("{, [ and ? in glob match themselves"), - "{glob}: {}", - output.text() - ); - } - let plain = - grep(&call, &json!({ "pattern": "zzz", "glob": "*.ts" })).expect("the search succeeds"); - assert!( - !plain.text().contains("match themselves"), - "{}", - plain.text() - ); -} - -#[test] -fn a_result_with_matches_carries_no_hint() { - let call = seeded(&[("/a.ts", "x\n")]); - let output = text_of(grep(&call, &json!({ "pattern": "x", "glob": "*.ts" }))); - assert_eq!(output, "/a.ts\n 1:x"); -} diff --git a/crates/plugin-std/src/grep/tests/paging.rs b/crates/plugin-std/src/grep/tests/paging.rs deleted file mode 100644 index 872cb50ed..000000000 --- a/crates/plugin-std/src/grep/tests/paging.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Paging through a result with `head_limit` and `offset`, and the -//! limits that stop a search before the tree is done. - -use serde_json::json; - -use super::fake::FakeSource; -use super::{grep, grep_limited, grep_over, seeded, text_of}; -use crate::grep::found::Limits; - -/// Twenty-five lines, each a match. -fn twenty_five() -> String { - let lines: Vec = (1..=25).map(|number| format!("hit {number}\n")).collect(); - lines.concat() -} - -/// The page of `/f.txt` that `head_limit` and `offset` select. -fn page(call: &runvil_plugin::testing::TestCall, offset: usize) -> String { - text_of(grep( - call, - &json!({ "pattern": "hit", "head_limit": 10, "offset": offset }), - )) -} - -#[test] -fn three_pages_of_ten_say_where_to_continue_and_join_into_the_whole_result() { - let call = seeded(&[("/f.txt", &twenty_five())]); - let first = page(&call, 0); - let second = page(&call, 10); - let third = page(&call, 20); - assert!( - first.ends_with( - "[Showing 1-10 of at least 11 matching lines. Use offset=10 to see more, or narrow \ - the search.]" - ), - "{first}" - ); - assert!( - second.ends_with( - "[Showing 11-20 of at least 21 matching lines. Use offset=20 to see more, or narrow \ - the search.]" - ), - "{second}" - ); - assert!( - third.ends_with("[Showing 21-25 of 25 matching lines.]"), - "{third}" - ); - let rows = |text: &str| -> Vec { - text.lines() - .filter(|line| line.starts_with(" ")) - .map(str::to_owned) - .collect() - }; - let unpaged = text_of(grep(&call, &json!({ "pattern": "hit", "head_limit": 0 }))); - assert!(!unpaged.contains("[Showing"), "no notice without a limit"); - let joined: Vec = [&first, &second, &third] - .into_iter() - .flat_map(|text| rows(text)) - .collect(); - assert_eq!(joined, rows(&unpaged)); -} - -#[test] -fn an_offset_past_the_end_says_there_are_no_more_results() { - let call = seeded(&[("/f.txt", &twenty_five())]); - let past = grep(&call, &json!({ "pattern": "hit", "offset": 30 })).expect("it succeeds"); - assert_eq!(past.text(), "No more results at offset=30."); -} - -#[test] -fn a_write_between_two_page_calls_shows_in_the_second_because_nothing_is_cached() { - let call = seeded(&[("/f.txt", &twenty_five())]); - let first = page(&call, 0); - assert!(!first.contains("changed"), "{first}"); - let changed = twenty_five().replace("hit 12", "hit changed"); - call.context() - .access() - .write("/f.txt", changed.as_bytes()) - .expect("the file is rewritten"); - let second = page(&call, 10); - assert!(second.contains(" 12:hit changed"), "{second}"); -} - -#[test] -fn a_lowered_file_limit_stops_the_search_with_a_notice_that_offers_no_offset() { - let call = seeded(&[ - ("/a", "m\n"), - ("/b", "m\n"), - ("/c", "m\n"), - ("/d", "m\n"), - ("/e", "m\n"), - ]); - let limits = Limits { - max_files: 3, - ..Limits::default() - }; - let output = text_of(grep_limited(&call, &json!({ "pattern": "m" }), &limits)); - assert_eq!( - output, - "/a\n 1:m\n\n/b\n 1:m\n\n/c\n 1:m\n\n\ - [Search stopped after considering 3 files. Narrow path, glob or type to search the \ - rest.]" - ); -} - -#[test] -fn a_file_limit_that_is_not_passed_prints_no_notice() { - let call = seeded(&[("/a", "m\n"), ("/b", "m\n"), ("/c", "m\n")]); - let limits = Limits { - max_files: 3, - ..Limits::default() - }; - let output = text_of(grep_limited(&call, &json!({ "pattern": "m" }), &limits)); - assert!(!output.contains("[Search stopped"), "{output}"); -} - -#[test] -fn a_lowered_byte_limit_stops_the_search_before_the_next_read() { - let source = FakeSource::default() - .file("/a", "match one\n") - .file("/b", "match two\n") - .file("/c", "match three\n"); - let limits = Limits { - max_bytes: 15, - ..Limits::default() - }; - let output = text_of(grep_over(&source, &json!({ "pattern": "match" }), &limits)); - assert_eq!( - output, - "/a\n 1:match one\n\n/b\n 1:match two\n\n\ - [Search stopped after reading 15 bytes. Narrow path, glob or type to search the rest.]" - ); -} - -#[test] -fn a_limit_stop_with_no_match_still_says_so() { - let source = FakeSource::default().file("/a", "x\n").file("/b", "x\n"); - let limits = Limits { - max_files: 1, - ..Limits::default() - }; - let output = grep_over(&source, &json!({ "pattern": "zzz" }), &limits) - .expect("the search succeeds") - .text() - .to_owned(); - assert!( - output.starts_with("No matches found. Searched 1 file."), - "{output}" - ); - assert!( - output.ends_with( - "[Search stopped after considering 1 files. Narrow path, glob or type to search the \ - rest.]" - ), - "{output}" - ); -} diff --git a/crates/plugin-std/src/grep/tests/patterns.rs b/crates/plugin-std/src/grep/tests/patterns.rs deleted file mode 100644 index d11322acf..000000000 --- a/crates/plugin-std/src/grep/tests/patterns.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! How Grep compiles and applies the pattern: the dialect, the flags, and -//! what a bad pattern says. - -use runvil_plugin::ErrorCode; -use serde_json::json; - -use super::{failure_of, grep, new_call, seeded, text_of}; - -/// The sentence every refused pattern ends with. -const ADVICE: &str = "Grep matches with Rust regex syntax, which has no look-around and no \ - back-references. To match the text literally, set literal to true."; - -#[test] -fn a_pattern_the_dialect_cannot_compile_is_an_error_naming_the_pattern_the_reason_and_the_dialect() -{ - let cases = [ - ( - "(?=x)y", - "look-around, including look-ahead and look-behind, is not supported", - ), - ( - "(?<=a)b", - "look-around, including look-ahead and look-behind, is not supported", - ), - ("(a)\\1", "backreferences are not supported"), - ("foo(", "unclosed group"), - ]; - for (pattern, reason) in cases { - let error = grep(&new_call(), &json!({ "pattern": pattern })) - .expect_err("the pattern does not compile"); - assert_eq!( - error.to_string(), - format!("std_Grep: Invalid regular expression \"{pattern}\": {reason}. {ADVICE}") - ); - assert_eq!(error.code, ErrorCode::ToolInvalidArguments, "{pattern}"); - } -} - -#[test] -fn a_bad_pattern_fails_before_any_path_is_looked_at() { - let message = failure_of(grep( - &new_call(), - &json!({ "pattern": "foo(", "path": "/missing" }), - )); - assert!(message.contains("Invalid regular expression"), "{message}"); -} - -#[test] -fn a_pattern_that_holds_the_word_error_still_gets_its_own_reason() { - let message = failure_of(grep(&new_call(), &json!({ "pattern": "error: (" }))); - assert_eq!( - message, - format!("std_Grep: Invalid regular expression \"error: (\": unclosed group. {ADVICE}") - ); -} - -#[test] -fn literal_matches_the_pattern_as_plain_text() { - let call = seeded(&[("/one.txt", "axb\n"), ("/two.txt", "a.b\n")]); - let regex = text_of(grep( - &call, - &json!({ "pattern": "a.b", "output_mode": "count" }), - )); - assert_eq!( - regex, "/one.txt:1\n/two.txt:1", - "as a regex the dot matches x" - ); - let literal = text_of(grep( - &call, - &json!({ "pattern": "a.b", "literal": true, "output_mode": "count" }), - )); - assert_eq!(literal, "/two.txt:1"); -} - -#[test] -fn literal_makes_a_pattern_that_would_not_compile_match_as_text() { - let call = seeded(&[("/a.txt", "call foo( now\n")]); - let output = text_of(grep(&call, &json!({ "pattern": "foo(", "literal": true }))); - assert_eq!(output, "/a.txt\n 1:call foo( now"); -} - -#[test] -fn matching_is_case_sensitive_unless_i_is_set() { - let call = seeded(&[ - ("/a.txt", "Alpha\n"), - ("/b.txt", "alpha\n"), - ("/c.txt", "ALPHA\n"), - ]); - let exact = text_of(grep( - &call, - &json!({ "pattern": "alpha", "output_mode": "count" }), - )); - assert_eq!(exact, "/b.txt:1"); - let folded = text_of(grep( - &call, - &json!({ "pattern": "alpha", "-i": true, "output_mode": "count" }), - )); - assert_eq!(folded, "/a.txt:1\n/b.txt:1\n/c.txt:1"); -} - -#[test] -fn a_pattern_that_could_backtrack_forever_in_other_engines_completes_on_a_long_line() { - let long = "a".repeat(1_600_000); - let call = seeded(&[("/long.txt", &long), ("/almost.txt", &format!("{long}b"))]); - let output = text_of(grep( - &call, - &json!({ "pattern": "^(a|aa)+$", "output_mode": "count" }), - )); - assert_eq!(output, "/long.txt:1"); -} - -#[test] -fn multiline_lets_dot_cross_a_line_break_and_a_pattern_span_lines() { - let call = seeded(&[("/a.txt", "a\nb\n")]); - let spanning = text_of(grep(&call, &json!({ "pattern": "a.b", "multiline": true }))); - assert_eq!(spanning, "/a.txt\n 1:a\n 2:b"); - let plain = grep(&call, &json!({ "pattern": "a.b" })).expect("the search succeeds"); - assert_eq!( - plain.text(), - "No matches found. Searched 1 file.\n\n\ - [No ignore rules ran: .gitignore is not applied, and only entries named .git are \ - skipped.]" - ); -} - -#[test] -fn multiline_counts_every_line_a_match_touches_once() { - let call = seeded(&[("/a.txt", "x\na\nb\nc\nx\n")]); - let output = text_of(grep( - &call, - &json!({ "pattern": "a\nb\nc", "multiline": true, "output_mode": "count" }), - )); - assert_eq!(output, "/a.txt:3"); -} - -#[test] -fn carets_and_dollars_match_at_each_line() { - let call = seeded(&[("/a.txt", "one\ntwo\nthree\n")]); - let output = text_of(grep( - &call, - &json!({ "pattern": "^t", "output_mode": "count" }), - )); - assert_eq!(output, "/a.txt:2"); - let end = text_of(grep( - &call, - &json!({ "pattern": "e$", "output_mode": "count" }), - )); - assert_eq!(end, "/a.txt:2"); -} diff --git a/crates/plugin-std/src/grep/tests/render.rs b/crates/plugin-std/src/grep/tests/render.rs deleted file mode 100644 index 17ece3595..000000000 --- a/crates/plugin-std/src/grep/tests/render.rs +++ /dev/null @@ -1,221 +0,0 @@ -//! The text of a result: the worked example, context, gaps, the count -//! mode, and the byte cap. - -use serde_json::json; - -use super::{grep, grep_limited, seeded, text_of}; -use crate::grep::found::Limits; - -/// A file of `lines` lines where line `n` of `at` holds `hit n` and every -/// other line holds `.`. -fn file_with_hits(lines: usize, at: &[usize]) -> String { - (1..=lines) - .map(|number| { - if at.contains(&number) { - format!("hit {number}\n") - } else { - ".\n".to_owned() - } - }) - .collect() -} - -#[test] -fn the_worked_example_prints_paths_numbered_lines_a_gap_and_no_trailing_newline() { - let call = seeded(&[ - ( - "/src/a.ts", - "// one\n// two\nconst alpha = 1\nlet beta = 2\n// five\n// six\n// seven\n// eight\n\ - const gamma = 3\nexport default gamma\n", - ), - ("/src/b.ts", "const first = 0\n"), - ]); - let output = text_of(grep(&call, &json!({ "pattern": "const", "-A": 1 }))); - assert_eq!( - output, - "/src/a.ts\n 3:const alpha = 1\n 4-let beta = 2\n --\n 9:const gamma = 3\n \ - 10-export default gamma\n\n/src/b.ts\n 1:const first = 0" - ); - let tail = &output[output.len() - 30..]; - assert!(tail.ends_with("const first = 0"), "{tail:?}"); - assert!(!output.ends_with('\n')); -} - -#[test] -fn context_c_with_a_override_prints_each_groups_lines_with_a_gap_between() { - let call = seeded(&[("/a.txt", &file_with_hits(30, &[5, 20]))]); - let output = text_of(grep(&call, &json!({ "pattern": "hit", "-C": 3, "-A": 1 }))); - let numbers: Vec = output - .lines() - .skip(1) - .map(|line| line.trim_start().to_owned()) - .collect(); - assert_eq!( - numbers, - [ - "2-.", - "3-.", - "4-.", - "5:hit 5", - "6-.", - "--", - "17-.", - "18-.", - "19-.", - "20:hit 20", - "21-." - ] - ); -} - -#[test] -fn overlapping_context_merges_and_no_line_prints_twice() { - let call = seeded(&[("/a.txt", &file_with_hits(12, &[5, 7]))]); - let output = text_of(grep(&call, &json!({ "pattern": "hit", "-C": 2 }))); - let numbers: Vec = output - .lines() - .skip(1) - .map(|line| line.trim_start().to_owned()) - .collect(); - assert_eq!( - numbers, - ["3-.", "4-.", "5:hit 5", "6-.", "7:hit 7", "8-.", "9-."], - "lines 3 to 9 once, with no gap line" - ); -} - -#[test] -fn touching_context_merges_without_a_gap() { - let call = seeded(&[("/a.txt", &file_with_hits(12, &[3, 8]))]); - let output = text_of(grep(&call, &json!({ "pattern": "hit", "-A": 2, "-B": 2 }))); - assert!(!output.contains("--"), "{output}"); -} - -#[test] -fn without_context_two_far_apart_matches_have_no_gap_line_between_them() { - let call = seeded(&[("/a.txt", &file_with_hits(30, &[5, 20]))]); - let output = text_of(grep(&call, &json!({ "pattern": "hit" }))); - assert_eq!(output, "/a.txt\n 5:hit 5\n 20:hit 20"); -} - -#[test] -fn a_blank_line_separates_files_and_none_ends_the_output() { - let call = seeded(&[("/a.txt", "x\n"), ("/b.txt", "x\n")]); - let output = text_of(grep(&call, &json!({ "pattern": "x" }))); - assert_eq!(output, "/a.txt\n 1:x\n\n/b.txt\n 1:x"); -} - -#[test] -fn count_mode_prints_the_path_and_its_matching_line_count() { - let call = seeded(&[("/a.txt", "x\nx\ny\nx\n"), ("/b.txt", "y\n")]); - let output = text_of(grep( - &call, - &json!({ "pattern": "x", "output_mode": "count" }), - )); - assert_eq!(output, "/a.txt:3"); -} - -#[test] -fn count_mode_counts_after_the_last_colon_when_the_name_has_one() { - let call = seeded(&[("/a:b.txt", "x\nx\n")]); - let output = text_of(grep( - &call, - &json!({ "pattern": "x", "output_mode": "count" }), - )); - assert_eq!(output, "/a:b.txt:2"); -} - -#[test] -fn the_byte_cap_cuts_the_body_on_a_line_and_says_where_to_continue() { - let line = "x".repeat(100); - let text = format!("{line}\n").repeat(400); - let call = seeded(&[("/f.txt", &text)]); - let output = text_of(grep(&call, &json!({ "pattern": "x" }))); - let (body, notice) = output - .split_once("\n\n") - .expect("a body and a notice are separated by a blank line"); - assert!(body.len() <= 15_000, "{} bytes", body.len()); - assert!(body.ends_with(&line), "the body ends on a whole line"); - assert_eq!( - notice, - "[Showing 1-141 of at least 142 matching lines. Output stopped at 15000 bytes. Use \ - offset=141 to see more, or narrow the search.]" - ); -} - -#[test] -fn the_notice_after_a_cap_cut_names_the_offset_that_resumes_exactly_there() { - let line = "x".repeat(100); - let text = format!("{line}\n").repeat(400); - let call = seeded(&[("/f.txt", &text)]); - let next = text_of(grep(&call, &json!({ "pattern": "x", "offset": 141 }))); - assert!(next.starts_with("/f.txt\n 142:"), "{}", &next[..40]); -} - -#[test] -fn the_cap_applies_to_the_other_modes_and_counts_each_line_with_its_newline() { - let call = seeded(&[("/a", "x\n"), ("/b", "x\n"), ("/c", "x\n")]); - let limits = Limits { - body_cap: 6, - ..Limits::default() - }; - let output = text_of(grep_limited( - &call, - &json!({ "pattern": "x", "output_mode": "files_with_matches" }), - &limits, - )); - assert_eq!( - output, - "/a\n/b\n\n[Showing 1-2 of at least 3 files. Output stopped at 6 bytes. Use offset=2 to \ - see more, or narrow the search.]" - ); -} - -#[test] -fn a_cut_inside_a_files_lines_never_leaves_context_or_a_path_dangling() { - let call = seeded(&[("/a.txt", &file_with_hits(40, &[10, 30]))]); - for cap in 1..120 { - let limits = Limits { - body_cap: cap, - ..Limits::default() - }; - let output = text_of(grep_limited( - &call, - &json!({ "pattern": "hit", "-C": 2 }), - &limits, - )); - let body = output.split("\n\n").next().expect("a body"); - assert!( - body.len() < cap.max(1) || body.starts_with('['), - "cap {cap}: {output}" - ); - let last = body.lines().last().unwrap_or_default(); - assert!( - !last.starts_with('/') && last != " --", - "cap {cap} left {last:?} dangling: {output}" - ); - if let Some(first_row) = body.lines().nth(1) { - let leading_context = first_row.contains('-'); - let has_match = body.lines().any(|row| row.contains(":hit")); - assert!( - !leading_context || has_match, - "cap {cap} shows context with no match: {output}" - ); - } - } -} - -#[test] -fn a_result_whose_first_entry_cannot_fit_says_so_instead_of_printing_nothing() { - let call = seeded(&[("/a.txt", "x\n")]); - let limits = Limits { - body_cap: 3, - ..Limits::default() - }; - let output = text_of(grep_limited(&call, &json!({ "pattern": "x" }), &limits)); - assert_eq!( - output, - "[Output stopped at 3 bytes before the first result fit. Narrow the search with path, \ - glob or type.]" - ); -} diff --git a/crates/plugin-std/src/grep/tests/search.rs b/crates/plugin-std/src/grep/tests/search.rs deleted file mode 100644 index ab8a335b7..000000000 --- a/crates/plugin-std/src/grep/tests/search.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! Which files Grep visits and in what order, how a head limit and an -//! offset count across files, and what is trusted. - -use runvil_plugin::OutputTrust; -use serde_json::json; - -use super::{grep, new_call, seeded, text_of}; - -/// The files that sort differently by name and by full path. -const TRICKY: [(&str, &str); 5] = [ - ("/B", "x\n"), - ("/a-b", "x\n"), - ("/a.txt", "x\n"), - ("/a/x", "x\n"), - ("/b", "x\n"), -]; - -/// A file of `lines` lines where each line in `at` holds `hit` and every -/// other line holds `.`. -fn file_with_hits(lines: usize, at: &[usize]) -> String { - (1..=lines) - .map(|number| if at.contains(&number) { "hit\n" } else { ".\n" }) - .collect() -} - -#[test] -fn files_come_in_full_path_order_in_every_mode() { - let call = seeded(&TRICKY); - let content = text_of(grep(&call, &json!({ "pattern": "x" }))); - assert_eq!( - content, - "/B\n 1:x\n\n/a-b\n 1:x\n\n/a.txt\n 1:x\n\n/a/x\n 1:x\n\n/b\n 1:x" - ); - let files = text_of(grep( - &call, - &json!({ "pattern": "x", "output_mode": "files_with_matches" }), - )); - assert_eq!(files, "/B\n/a-b\n/a.txt\n/a/x\n/b"); - let count = text_of(grep( - &call, - &json!({ "pattern": "x", "output_mode": "count" }), - )); - assert_eq!(count, "/B:1\n/a-b:1\n/a.txt:1\n/a/x:1\n/b:1"); -} - -#[test] -fn the_order_is_the_order_glob_gives() { - let call = seeded(&TRICKY); - let globbed = call - .context() - .access() - .glob("/**") - .expect("the glob matches"); - let files: Vec = globbed - .into_iter() - .filter(|path| TRICKY.iter().any(|(file, _)| file == path)) - .collect(); - let searched = text_of(grep( - &call, - &json!({ "pattern": "x", "output_mode": "files_with_matches" }), - )); - assert_eq!(searched, files.join("\n")); -} - -#[test] -fn a_search_of_one_directory_prints_paths_as_absolute_virtual_paths() { - let call = seeded(&[("/proj/src/a.txt", "x\n"), ("/other.txt", "x\n")]); - for path in ["/proj", "/proj/", "proj"] { - let output = text_of(grep( - &call, - &json!({ "pattern": "x", "path": path, "output_mode": "files_with_matches" }), - )); - assert_eq!(output, "/proj/src/a.txt", "{path}"); - } -} - -#[test] -fn a_search_of_one_file_names_that_file() { - let call = seeded(&[("/proj/a.txt", "one\ntwo\n"), ("/proj/b.txt", "two\n")]); - let output = text_of(grep( - &call, - &json!({ "pattern": "two", "path": "/proj/a.txt" }), - )); - assert_eq!(output, "/proj/a.txt\n 2:two"); -} - -#[test] -fn entries_named_git_are_skipped_at_any_depth_and_other_dotfiles_are_searched() { - let call = seeded(&[ - ("/.git/config", "x\n"), - ("/sub/.git/HEAD", "x\n"), - ("/.gitignore", "x\n"), - ("/.env", "x\n"), - ("/target/out.txt", "x\n"), - ]); - let output = text_of(grep( - &call, - &json!({ "pattern": "x", "output_mode": "files_with_matches" }), - )); - assert_eq!(output, "/.env\n/.gitignore\n/target/out.txt"); -} - -#[test] -fn head_limit_counts_across_files_and_stops_inside_the_file_it_reaches() { - let call = seeded(&[ - ("/a.ts", &file_with_hits(45, &[12, 40])), - ("/b.ts", &file_with_hits(9, &[1, 2, 3])), - ]); - let output = text_of(grep(&call, &json!({ "pattern": "hit", "head_limit": 2 }))); - assert_eq!( - output, - "/a.ts\n 12:hit\n 40:hit\n\n\ - [Showing 1-2 of at least 3 matching lines. Use offset=2 to see more, or narrow the \ - search.]" - ); -} - -#[test] -fn an_offset_skips_entries_across_files_before_the_head_limit_applies() { - let call = seeded(&[ - ("/f1", "m\nm\nm\n"), - ("/f2", "m\nm\nm\n"), - ("/f3", "m\nm\nm\n"), - ]); - let output = text_of(grep( - &call, - &json!({ "pattern": "m", "offset": 4, "head_limit": 2 }), - )); - assert_eq!( - output, - "/f2\n 2:m\n 3:m\n\n\ - [Showing 5-6 of at least 7 matching lines. Use offset=6 to see more, or narrow the \ - search.]" - ); -} - -#[test] -fn head_limit_zero_prints_every_match() { - let text = "m\n".repeat(40); - let call = seeded(&[("/a.txt", &text)]); - let output = text_of(grep(&call, &json!({ "pattern": "m", "head_limit": 0 }))); - assert_eq!( - output.lines().count(), - 41, - "the path and 40 matches, no notice" - ); - assert!(output.ends_with("\n 40:m"), "{output}"); -} - -#[test] -fn a_head_limit_that_exactly_fits_prints_no_notice() { - let call = seeded(&[("/a.txt", "m\nm\n")]); - let output = text_of(grep(&call, &json!({ "pattern": "m", "head_limit": 2 }))); - assert_eq!(output, "/a.txt\n 1:m\n 2:m"); -} - -#[test] -fn head_limit_with_context_counts_entries_and_keeps_their_context_lines() { - let call = seeded(&[("/a.txt", &file_with_hits(30, &[1, 20, 28]))]); - let output = text_of(grep( - &call, - &json!({ "pattern": "hit", "-A": 5, "head_limit": 2 }), - )); - assert_eq!( - output, - "/a.txt\n 1:hit\n 2-.\n 3-.\n 4-.\n 5-.\n 6-.\n --\n 20:hit\n 21-.\n 22-.\n \ - 23-.\n 24-.\n 25-.\n\n\ - [Showing 1-2 of at least 3 matching lines. Use offset=2 to see more, or narrow the \ - search.]" - ); -} - -#[test] -fn files_with_matches_and_count_page_by_file() { - let call = seeded(&[("/a", "m\nm\n"), ("/b", "m\n"), ("/c", "m\n")]); - let files = text_of(grep( - &call, - &json!({ "pattern": "m", "output_mode": "files_with_matches", "head_limit": 2 }), - )); - assert_eq!( - files, - "/a\n/b\n\n[Showing 1-2 of at least 3 files. Use offset=2 to see more, or narrow the \ - search.]" - ); - let count = text_of(grep( - &call, - &json!({ "pattern": "m", "output_mode": "count", "offset": 1 }), - )); - assert_eq!(count, "/b:1\n/c:1\n\n[Showing 2-3 of 3 files.]"); -} - -#[test] -fn a_result_with_matches_is_untrusted_output() { - let call = seeded(&[("/a.txt", "needle\n")]); - let output = grep(&call, &json!({ "pattern": "needle" })).expect("the search succeeds"); - assert_eq!(output.trust(), OutputTrust::Untrusted); -} - -#[test] -fn a_bare_zero_match_result_is_trusted_and_a_past_the_end_one_is_not() { - let call = seeded(&[("/a.txt", "needle\n")]); - let none = grep(&call, &json!({ "pattern": "absent" })).expect("the search succeeds"); - assert_eq!(none.trust(), OutputTrust::Trusted); - assert_eq!( - none.text(), - "No matches found. Searched 1 file.\n\n\ - [No ignore rules ran: .gitignore is not applied, and only entries named .git are \ - skipped.]" - ); - let past = - grep(&call, &json!({ "pattern": "needle", "offset": 5 })).expect("the search succeeds"); - assert_eq!(past.text(), "No more results at offset=5."); - assert_eq!(past.trust(), OutputTrust::Untrusted); -} - -#[test] -fn searching_nothing_says_no_files_were_searched() { - let output = grep(&new_call(), &json!({ "pattern": "x" })).expect("the search succeeds"); - assert!( - output - .text() - .starts_with("No matches found. Searched 0 files."), - "{}", - output.text() - ); -} - -#[test] -fn the_description_names_the_file_limit_the_code_uses() { - let text = &super::definition().description; - let limit = crate::grep::found::MAX_FILES.to_string(); - assert!(text.contains(&limit), "{limit} is named: {text}"); -} diff --git a/crates/plugin-std/src/grep/tests/skips.rs b/crates/plugin-std/src/grep/tests/skips.rs deleted file mode 100644 index 8f1662fcb..000000000 --- a/crates/plugin-std/src/grep/tests/skips.rs +++ /dev/null @@ -1,390 +0,0 @@ -//! What Grep skips and counts instead of failing, and how the limits and -//! the pauses behave, through a [`Source`](crate::grep::source::Source) -//! that can produce what the in-memory backend cannot. - -use runvil_plugin::{OutputTrust, VfsError}; -use serde_json::json; - -use super::fake::{FakeSource, Node}; -use super::{block_on, count_pendings, definition, failure_of, grep_over, text_of}; -use crate::grep::args; -use crate::grep::found::Limits; - -/// The error for a path the policy refuses. -fn denied(path: &str) -> VfsError { - VfsError::PermissionDenied { - path: path.to_owned(), - reason: "the policy refuses it".to_owned(), - } -} - -/// A tree with one of everything Grep skips, and one clean match. -fn messy() -> FakeSource { - FakeSource::default() - .with("/a.bin", Node::File(b"NEEDLE\0\0".to_vec())) - .file("/bad\u{1}name", "NEEDLE\n") - .with("/big.log", Node::Sized(1000, b"NEEDLE\n".to_vec())) - .with( - "/busy.txt", - Node::ReadFails(VfsError::Conflict { - path: "/busy.txt".to_owned(), - detail: "another call".to_owned(), - }), - ) - .with("/denied.txt", Node::ReadFails(denied("/denied.txt"))) - .with("/deny", Node::ListFails(denied("/deny"))) - .with( - "/io.txt", - Node::ReadFails(VfsError::Io { - path: "/io.txt".to_owned(), - kind: std::io::ErrorKind::Other, - raw_os_error: None, - message: "disk error".to_owned(), - }), - ) - .with("/link", Node::Link) - .with("/pipe", Node::Pipe) - .file("/ok.txt", "NEEDLE here\n") -} - -/// The limits the skip tests lower the file size under. -fn small_files() -> Limits { - Limits { - max_file_size: 100, - ..Limits::default() - } -} - -/// The skip line every `messy` search ends with. -const MESSY_SKIPS: &str = "[Skipped 9 entries: 1 binary file, 2 files that could not be read, 1 \ - file over 100 bytes, 1 symbolic link, 1 name with a control character, 1 entry held by \ - another call in this round, 2 entries denied by policy. First: /a.bin, /big.log, \ - /busy.txt, /denied.txt, /deny]"; - -#[test] -fn every_kind_of_skip_is_counted_and_the_clean_match_is_still_found() { - let output = text_of(grep_over( - &messy(), - &json!({ "pattern": "NEEDLE" }), - &small_files(), - )); - assert_eq!(output, format!("/ok.txt\n 1:NEEDLE here\n\n{MESSY_SKIPS}")); -} - -#[test] -fn a_zero_match_result_keeps_the_skip_line_and_the_searched_count() { - let output = grep_over(&messy(), &json!({ "pattern": "absent" }), &small_files()) - .expect("the search succeeds"); - assert_eq!( - output.trust(), - OutputTrust::Untrusted, - "it names skipped paths" - ); - assert_eq!( - output.text(), - format!( - "No matches found. Searched 1 file.\n\n{MESSY_SKIPS}\n\ - [No ignore rules ran: .gitignore is not applied, and only entries named .git are \ - skipped.]" - ) - ); -} - -#[test] -fn a_binary_file_is_skipped_in_every_output_mode() { - let source = FakeSource::default().with("/a.bin", Node::File(b"NEEDLE\0".to_vec())); - for mode in ["content", "files_with_matches", "count"] { - let output = grep_over( - &source, - &json!({ "pattern": "NEEDLE", "output_mode": mode }), - &Limits::default(), - ) - .expect("the search succeeds"); - assert_eq!( - output.text(), - "No matches found. Searched 0 files.\n\n\ - [Skipped 1 entry: 1 binary file. First: /a.bin]\n\ - [No ignore rules ran: .gitignore is not applied, and only entries named .git are \ - skipped.]", - "{mode}" - ); - } -} - -#[test] -fn the_skip_line_for_one_skip_uses_the_singular() { - let source = FakeSource::default() - .with("/a.bin", Node::File(b"x\0".to_vec())) - .file("/ok.txt", "NEEDLE\n"); - let output = text_of(grep_over( - &source, - &json!({ "pattern": "NEEDLE" }), - &Limits::default(), - )); - assert_eq!( - output, - "/ok.txt\n 1:NEEDLE\n\n[Skipped 1 entry: 1 binary file. First: /a.bin]" - ); -} - -#[test] -fn a_control_character_name_is_counted_and_never_printed() { - let source = FakeSource::default() - .file("/a\u{7f}b", "NEEDLE\n") - .file("/ok.txt", "NEEDLE\n"); - let output = text_of(grep_over( - &source, - &json!({ "pattern": "NEEDLE" }), - &Limits::default(), - )); - assert_eq!( - output, - "/ok.txt\n 1:NEEDLE\n\n[Skipped 1 entry: 1 name with a control character.]" - ); -} - -#[test] -fn only_the_first_five_printable_skips_are_named() { - let mut source = FakeSource::default(); - for index in 0..8 { - source = source.with(&format!("/f{index}.bin"), Node::File(b"\0".to_vec())); - } - let output = grep_over(&source, &json!({ "pattern": "x" }), &Limits::default()) - .expect("the search succeeds"); - assert!( - output.text().contains( - "[Skipped 8 entries: 8 binary files. First: /f0.bin, /f1.bin, /f2.bin, /f3.bin, \ - /f4.bin]" - ), - "{}", - output.text() - ); -} - -#[test] -fn a_directory_that_cannot_be_listed_is_skipped_by_why() { - let source = FakeSource::default() - .with( - "/io", - Node::ListFails(VfsError::Io { - path: "/io".to_owned(), - kind: std::io::ErrorKind::Other, - raw_os_error: None, - message: "disk error".to_owned(), - }), - ) - .with( - "/locked", - Node::ListFails(VfsError::Conflict { - path: "/locked".to_owned(), - detail: "another call".to_owned(), - }), - ) - .with("/policy", Node::ListFails(denied("/policy"))) - .file("/ok.txt", "NEEDLE\n"); - let output = text_of(grep_over( - &source, - &json!({ "pattern": "NEEDLE" }), - &Limits::default(), - )); - assert_eq!( - output, - "/ok.txt\n 1:NEEDLE\n\n[Skipped 3 entries: 1 directory that could not be read, 1 entry \ - held by another call in this round, 1 entry denied by policy. First: /io, /locked, \ - /policy]" - ); -} - -#[test] -fn a_pipe_is_never_opened_and_is_skipped_as_unreadable() { - let source = FakeSource::default().with("/pipe", Node::Pipe); - let output = grep_over(&source, &json!({ "pattern": "x" }), &Limits::default()) - .expect("the search succeeds"); - assert!( - output - .text() - .contains("[Skipped 1 entry: 1 file that could not be read. First: /pipe]"), - "{}", - output.text() - ); -} - -#[test] -fn a_file_over_the_size_limit_is_skipped_before_any_read() { - let source = FakeSource::default().with("/big.log", Node::Sized(1000, b"NEEDLE\n".to_vec())); - let output = grep_over(&source, &json!({ "pattern": "NEEDLE" }), &small_files()) - .expect("the search succeeds"); - assert!( - output - .text() - .contains("[Skipped 1 entry: 1 file over 100 bytes. First: /big.log]"), - "{}", - output.text() - ); -} - -#[test] -fn a_missing_path_fails_with_the_does_not_exist_text() { - let source = FakeSource::default().file("/a.txt", "x\n"); - let message = failure_of(grep_over( - &source, - &json!({ "pattern": "x", "path": "/x" }), - &Limits::default(), - )); - assert_eq!(message, "Path does not exist: /x"); -} - -#[test] -fn a_root_that_cannot_be_listed_or_statted_fails_through_the_refusal_text() { - let source = FakeSource::default().with("/deny", Node::ListFails(denied("/deny"))); - let message = failure_of(grep_over( - &source, - &json!({ "pattern": "x", "path": "/deny" }), - &Limits::default(), - )); - assert_eq!(message, "the policy refuses it"); -} - -#[test] -fn a_root_that_is_a_pipe_is_neither_a_file_nor_a_directory() { - let source = FakeSource::default().with("/pipe", Node::Pipe); - let message = failure_of(grep_over( - &source, - &json!({ "pattern": "x", "path": "/pipe" }), - &Limits::default(), - )); - assert_eq!(message, "Path is neither a file nor a directory: /pipe"); -} - -#[test] -fn a_named_file_is_searched_alone_and_a_failed_read_of_it_fails_the_call() { - let source = FakeSource::default() - .file("/a.txt", "NEEDLE\n") - .with("/denied.txt", Node::ReadFails(denied("/denied.txt"))); - let output = text_of(grep_over( - &source, - &json!({ "pattern": "NEEDLE", "path": "/a.txt" }), - &Limits::default(), - )); - assert_eq!(output, "/a.txt\n 1:NEEDLE"); - let message = failure_of(grep_over( - &source, - &json!({ "pattern": "NEEDLE", "path": "/denied.txt" }), - &Limits::default(), - )); - assert_eq!(message, "the policy refuses it"); -} - -#[test] -fn a_named_file_over_the_size_limit_is_a_skip() { - let source = FakeSource::default().with("/big", Node::Sized(1000, b"NEEDLE\n".to_vec())); - let output = grep_over( - &source, - &json!({ "pattern": "NEEDLE", "path": "/big" }), - &small_files(), - ) - .expect("the search succeeds"); - assert!( - output - .text() - .starts_with("No matches found. Searched 0 files.\n\n[Skipped 1 entry: 1 file over 100 bytes. First: /big]"), - "{}", - output.text() - ); -} - -#[test] -fn a_named_link_is_searched_through_as_a_directory_and_then_as_a_file() { - let source = FakeSource::default() - .with("/dirlink", Node::Link) - .file("/dirlink/a.txt", "NEEDLE\n") - .with("/filelink", Node::LinkToFile(b"NEEDLE inside\n".to_vec())); - let through_dir = text_of(grep_over( - &source, - &json!({ "pattern": "NEEDLE", "path": "/dirlink" }), - &Limits::default(), - )); - assert_eq!(through_dir, "/dirlink/a.txt\n 1:NEEDLE"); - let through_file = text_of(grep_over( - &source, - &json!({ "pattern": "NEEDLE", "path": "/filelink" }), - &Limits::default(), - )); - assert_eq!(through_file, "/filelink\n 1:NEEDLE inside"); -} - -#[test] -fn a_named_link_to_an_over_size_file_is_a_skip_found_after_a_bounded_read() { - let source = FakeSource::default().with("/filelink", Node::LinkToFile(vec![b'a'; 500])); - let output = grep_over( - &source, - &json!({ "pattern": "a", "path": "/filelink" }), - &small_files(), - ) - .expect("the search succeeds"); - assert!( - output - .text() - .contains("[Skipped 1 entry: 1 file over 100 bytes. First: /filelink]"), - "{}", - output.text() - ); -} - -#[test] -fn the_search_pauses_once_per_slice_of_files() { - let mut source = FakeSource::default(); - for index in 0..10 { - source = source.file(&format!("/f{index}"), "x\n"); - } - let limits = Limits { - slice_files: 3, - ..Limits::default() - }; - let definition = definition(); - let args = json!({ "pattern": "x", "output_mode": "count" }); - let request = args::parse(definition, &args).expect("the arguments parse"); - let (output, pending) = count_pendings(crate::grep::search(&source, "Grep", &request, &limits)); - assert!(output.is_ok()); - assert_eq!(pending, 3, "after files 3, 6, and 9"); -} - -#[test] -fn the_search_pauses_once_per_slice_of_bytes() { - let mut source = FakeSource::default(); - for index in 0..10 { - source = source.file(&format!("/f{index}"), "0123456789"); - } - let limits = Limits { - slice_bytes: 25, - ..Limits::default() - }; - let definition = definition(); - let args = json!({ "pattern": "x" }); - let request = args::parse(definition, &args).expect("the arguments parse"); - let (output, pending) = count_pendings(crate::grep::search(&source, "Grep", &request, &limits)); - assert!(output.is_ok()); - assert_eq!(pending, 3, "after files 3, 6, and 9, at 30 bytes each time"); -} - -#[test] -fn a_search_dropped_after_its_first_pause_leaves_the_source_usable() { - let mut source = FakeSource::default(); - for index in 0..10 { - source = source.file(&format!("/f{index}"), "x\n"); - } - let limits = Limits { - slice_files: 2, - ..Limits::default() - }; - let definition = definition(); - let args = json!({ "pattern": "x" }); - let request = args::parse(definition, &args).expect("the arguments parse"); - { - let mut future = Box::pin(crate::grep::search(&source, "Grep", &request, &limits)); - let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); - assert!(future.as_mut().poll(&mut cx).is_pending()); - } - let again = block_on(crate::grep::search(&source, "Grep", &request, &limits)); - assert!(again.is_ok(), "a fresh search still runs"); -} diff --git a/crates/plugin-std/src/grep/tests/text.rs b/crates/plugin-std/src/grep/tests/text.rs deleted file mode 100644 index 7f386bae8..000000000 --- a/crates/plugin-std/src/grep/tests/text.rs +++ /dev/null @@ -1,197 +0,0 @@ -//! How Grep reads a file's bytes: through the same `text` module Read -//! uses, so a Grep line number is the number Read prints for that line. - -use runvil_plugin::OutputTrust; -use serde_json::json; - -use super::fake::{FakeSource, Node}; -use super::{grep_over, text_of}; -use crate::grep::found::Limits; - -/// The tree with `bytes` as the file `/a.txt`. -fn one(bytes: &[u8]) -> FakeSource { - FakeSource::default().with("/a.txt", Node::File(bytes.to_vec())) -} - -/// The text of searching `source` for `pattern`. -fn search(source: &FakeSource, pattern: &str) -> String { - text_of(grep_over( - source, - &json!({ "pattern": pattern }), - &Limits::default(), - )) -} - -/// `text` as UTF-16 little-endian code units, with a byte order mark when -/// `mark` is set. -fn utf16le(text: &str, mark: bool) -> Vec { - let mut bytes = if mark { vec![0xFF, 0xFE] } else { Vec::new() }; - for unit in text.encode_utf16() { - bytes.extend_from_slice(&unit.to_le_bytes()); - } - bytes -} - -#[test] -fn invalid_utf8_matches_with_the_replacement_character_and_a_note() { - let output = search(&one(b"caf\xE9 NEEDLE\n"), "NEEDLE"); - assert_eq!( - output, - "/a.txt\n 1:caf\u{FFFD} NEEDLE\n\n\ - [Note: invalid UTF-8 in 1 file was shown as U+FFFD: /a.txt]" - ); -} - -#[test] -fn a_byte_order_mark_is_dropped_so_a_caret_matches_the_first_line() { - let mut bytes = vec![0xEF, 0xBB, 0xBF]; - bytes.extend_from_slice(b"NEEDLE first\nsecond\n"); - let output = search(&one(&bytes), "^NEEDLE"); - assert_eq!(output, "/a.txt\n 1:NEEDLE first"); -} - -#[test] -fn a_carriage_return_before_a_line_feed_is_not_part_of_the_line() { - let output = search(&one(b"a\r\nNEEDLE\r\n"), "NEEDLE"); - assert_eq!(output, "/a.txt\n 2:NEEDLE"); - let anchored = search(&one(b"a\r\nNEEDLE\r\n"), "NEEDLE$"); - assert_eq!(anchored, "/a.txt\n 2:NEEDLE"); -} - -#[test] -fn a_lone_carriage_return_does_not_start_a_line() { - let output = search(&one(b"first\rNEEDLE\rthird"), "NEEDLE"); - assert_eq!(output, "/a.txt\n 1:first\rNEEDLE\rthird"); -} - -#[test] -fn utf16_with_a_mark_is_decoded_and_numbered_by_its_lines() { - let output = search(&one(&utf16le("a\nNEEDLE\n", true)), "NEEDLE"); - assert_eq!(output, "/a.txt\n 2:NEEDLE"); -} - -#[test] -fn utf16_without_a_mark_is_one_binary_skip() { - let output = grep_over( - &one(&utf16le("NEEDLE\n", false)), - &json!({ "pattern": "NEEDLE" }), - &Limits::default(), - ) - .expect("the search succeeds"); - assert!( - output - .text() - .contains("[Skipped 1 entry: 1 binary file. First: /a.txt]"), - "{}", - output.text() - ); -} - -#[test] -fn five_control_bytes_in_a_hundred_make_a_file_binary_and_four_do_not() { - let body = |controls: usize| { - let mut bytes = vec![0x01; controls]; - bytes.extend_from_slice(b"NEEDLE"); - bytes.resize(100, b'a'); - bytes - }; - let binary = grep_over( - &one(&body(5)), - &json!({ "pattern": "NEEDLE" }), - &Limits::default(), - ) - .expect("the search succeeds"); - assert!(binary.text().contains("1 binary file"), "{}", binary.text()); - let text = grep_over( - &one(&body(4)), - &json!({ "pattern": "NEEDLE", "output_mode": "files_with_matches" }), - &Limits::default(), - ) - .expect("the search succeeds"); - assert_eq!(text.text(), "/a.txt"); -} - -#[test] -fn a_precomposed_character_and_a_combining_pair_each_match_only_their_own_file() { - let source = FakeSource::default() - .file("/one.txt", "caf\u{e9}\n") - .file("/two.txt", "cafe\u{301}\n"); - let precomposed = text_of(grep_over( - &source, - &json!({ "pattern": "caf\u{e9}", "output_mode": "files_with_matches" }), - &Limits::default(), - )); - assert_eq!(precomposed, "/one.txt"); - let combining = text_of(grep_over( - &source, - &json!({ "pattern": "cafe\u{301}", "output_mode": "files_with_matches" }), - &Limits::default(), - )); - assert_eq!(combining, "/two.txt"); -} - -#[test] -fn two_replaced_files_are_named_in_one_note() { - let source = FakeSource::default() - .with("/a.txt", Node::File(b"x \xFF\n".to_vec())) - .with("/b.txt", Node::File(b"x \xFE\n".to_vec())); - let output = text_of(grep_over( - &source, - &json!({ "pattern": "x", "output_mode": "files_with_matches" }), - &Limits::default(), - )); - assert_eq!( - output, - "/a.txt\n/b.txt\n\n\ - [Note: invalid UTF-8 in 2 files was shown as U+FFFD: /a.txt, /b.txt]" - ); -} - -#[test] -fn a_note_names_the_first_five_files_and_counts_the_rest() { - let mut source = FakeSource::default(); - for index in 0..7 { - source = source.with(&format!("/f{index}.txt"), Node::File(b"x \xFF\n".to_vec())); - } - let output = text_of(grep_over( - &source, - &json!({ "pattern": "x", "output_mode": "count" }), - &Limits::default(), - )); - assert!( - output.ends_with( - "[Note: invalid UTF-8 in 7 files was shown as U+FFFD: /f0.txt, /f1.txt, /f2.txt, \ - /f3.txt, /f4.txt, and 2 more]" - ), - "{output}" - ); -} - -#[test] -fn a_replacement_note_on_a_zero_match_result_makes_it_untrusted() { - let output = grep_over( - &one(b"caf\xE9\n"), - &json!({ "pattern": "absent" }), - &Limits::default(), - ) - .expect("the search succeeds"); - assert_eq!(output.trust(), OutputTrust::Untrusted); - assert!( - output.text().contains("[Note: invalid UTF-8 in 1 file"), - "{}", - output.text() - ); -} - -#[test] -fn an_empty_file_has_no_lines_to_match() { - let output = grep_over(&one(b""), &json!({ "pattern": "^" }), &Limits::default()) - .expect("the search succeeds"); - assert!( - output - .text() - .starts_with("No matches found. Searched 1 file."), - "{}", - output.text() - ); -} diff --git a/crates/plugin-std/src/grep/tests/window.rs b/crates/plugin-std/src/grep/tests/window.rs deleted file mode 100644 index 7134d339d..000000000 --- a/crates/plugin-std/src/grep/tests/window.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! The two cuts inside a line: the window around a match and the head of -//! a context line. No cut may split a code point, and the omitted counts -//! plus what is shown must equal the line. - -use serde_json::json; - -use super::{grep_limited, seeded, text_of}; -use crate::grep::found::Limits; -use crate::grep::window::{Window, context_text, match_text}; - -/// Splits shown text into the bytes omitted before it, the window, and -/// the bytes omitted after it. -fn parts(shown: &str) -> (usize, &str, usize) { - let (before, rest) = match shown.strip_prefix("[... ") { - Some(after_open) => { - let (count, rest) = after_open - .split_once(" bytes omitted]") - .expect("an opening marker closes"); - (count.parse().expect("the count is a number"), rest) - } - None => (0, shown), - }; - match rest.rfind("[... ") { - Some(open) if rest.ends_with(" bytes omitted]") => { - let count = rest[open + "[... ".len()..] - .strip_suffix(" bytes omitted]") - .expect("the marker closes the text") - .parse() - .expect("the count is a number"); - (before, &rest[..open], count) - } - _ => (before, rest, 0), - } -} - -/// Asserts that `shown` is a well-formed cut of `line`: the window is a -/// whole-character slice of it at the offset the first count gives, and -/// the counts plus the window add up to the line. -fn assert_cut_of(line: &str, shown: &str) { - let (before, window, after) = parts(shown); - assert_eq!( - before + window.len() + after, - line.len(), - "the counts and the window cover the line: {shown:?}" - ); - assert_eq!(line.get(before..before + window.len()), Some(window)); -} - -#[test] -fn a_line_within_the_width_is_shown_whole() { - let window = Window::default(); - let line = "y".repeat(1000); - assert_eq!(match_text(&line, 500, &window), line); - assert_eq!(context_text(&line, &window), line); -} - -#[test] -fn a_match_past_the_first_thousand_bytes_is_still_shown_with_both_markers() { - let line = format!("{}NEEDLE{}", "a".repeat(1001), "a".repeat(993)); - assert_eq!(line.len(), 2000); - let shown = match_text(&line, 1001, &Window::default()); - assert!(shown.contains("NEEDLE"), "{shown}"); - assert!(shown.starts_with("[... 801 bytes omitted]"), "{shown}"); - assert!(shown.ends_with("[... 199 bytes omitted]"), "{shown}"); - let (before, window, after) = parts(&shown); - assert_eq!((before, window.len(), after), (801, 1000, 199)); - assert_eq!(before + window.len() + after, line.len()); -} - -#[test] -fn a_match_near_the_start_has_only_a_trailing_marker_and_one_near_the_end_only_a_leading() { - let line = format!("NEEDLE{}", "a".repeat(2000)); - let head = match_text(&line, 0, &Window::default()); - assert!(!head.starts_with("[... "), "{head}"); - assert_cut_of(&line, &head); - let end = "a".repeat(2000) + "NEEDLE"; - let tail = match_text(&end, 2000, &Window::default()); - assert!(tail.starts_with("[... "), "{tail}"); - assert!(!tail.ends_with(" bytes omitted]"), "{tail}"); - assert!(tail.contains("NEEDLE"), "{tail}"); - assert_cut_of(&end, &tail); -} - -#[test] -fn a_line_of_cjk_characters_is_cut_on_character_boundaries() { - let line = "日本語".repeat(200); - assert_eq!(line.chars().count(), 600); - for first in [0, 700, 899, 1000, 1801] { - let shown = match_text(&line, first, &Window::default()); - assert_cut_of(&line, &shown); - } -} - -#[test] -fn a_line_of_four_byte_emoji_with_the_match_in_the_middle_is_cut_on_character_boundaries() { - let line = "😀".repeat(1000); - assert_eq!(line.len(), 4000); - for first in 2000..2004 { - let shown = match_text(&line, first, &Window::default()); - assert_cut_of(&line, &shown); - let (_, window, _) = parts(&shown); - assert!(window.chars().all(|c| c == '😀'), "whole characters only"); - } -} - -#[test] -fn a_context_line_over_the_width_keeps_its_head_and_counts_the_rest() { - let line = "日".repeat(600); - let shown = context_text(&line, &Window::default()); - let (before, window, after) = parts(&shown); - assert_eq!(before, 0); - assert!(window.len() <= 1000, "{} bytes", window.len()); - assert_eq!(window.len(), 999, "333 whole characters"); - assert_eq!(after, 1800 - 999); - assert_cut_of(&line, &shown); -} - -#[test] -fn sweeping_the_window_start_over_every_byte_of_a_three_byte_character_never_splits_it() { - let line = format!("{}日{}", "x".repeat(1500), "y".repeat(1500)); - for first in 1690..1710 { - let shown = match_text(&line, first, &Window::default()); - assert_cut_of(&line, &shown); - } -} - -#[test] -fn sweeping_the_width_and_lead_over_multibyte_text_keeps_every_cut_whole() { - let lines = [ - "日本語のテキスト".repeat(5), - "😀🎉".repeat(12), - format!("é{}ß{}", "a".repeat(9), "日".repeat(9)), - ]; - for line in &lines { - for width in 1..30 { - for lead in [0, 1, 2, 3, 5, 8] { - let window = Window { width, lead }; - for first in 0..line.len() { - assert_cut_of(line, &match_text(line, first, &window)); - } - assert_cut_of(line, &context_text(line, &window)); - } - } - } -} - -#[test] -fn sweeping_the_body_cap_and_window_over_emoji_and_cjk_text_keeps_the_body_whole_lines() { - let text = format!( - "{}\n{}\n{}\n", - "日本語".repeat(12), - "😀 needle 🎉".repeat(4), - "needle é" - ); - let call = seeded(&[("/a.txt", &text)]); - for width in [1, 2, 3, 4, 7, 10, 25] { - for lead in [0, 1, 4] { - for cap in (10..260).step_by(13) { - let limits = Limits { - body_cap: cap, - window: Window { width, lead }, - ..Limits::default() - }; - let output = text_of(grep_limited( - &call, - &json!({ "pattern": "needle", "-C": 1 }), - &limits, - )); - let body = output.split("\n\n").next().expect("a body"); - assert!( - body.len() <= cap || body.starts_with('['), - "cap {cap} width {width}: {output}" - ); - for row in body.lines() { - let numbered = row - .strip_prefix(" ") - .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit())); - assert!( - row.starts_with('/') || row == " --" || numbered || row.starts_with('['), - "cap {cap} width {width}: {row:?} is not a whole row in {output}" - ); - } - } - } - } -} - -#[test] -fn a_long_matching_line_in_a_search_is_shown_as_a_window_with_its_markers() { - let line = format!("{}NEEDLE{}", "a".repeat(1500), "b".repeat(1500)); - let call = seeded(&[("/long.txt", &format!("{line}\n"))]); - let output = text_of(super::grep(&call, &json!({ "pattern": "NEEDLE" }))); - let row = output.lines().nth(1).expect("a match row"); - let shown = row.strip_prefix(" 1:").expect("the row is numbered"); - assert!(shown.contains("NEEDLE"), "{shown}"); - assert_cut_of(&line, shown); - let (before, _, _) = parts(shown); - assert_eq!(before, 1300, "the window starts 200 bytes before the match"); -} diff --git a/crates/plugin-std/src/grep/types.rs b/crates/plugin-std/src/grep/types.rs deleted file mode 100644 index dca644961..000000000 --- a/crates/plugin-std/src/grep/types.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Grep's `type` filter: a short name for a set of file extensions. - -/// Each type name beside the extensions it keeps, without the dot. -const TYPES: [(&str, &[&str]); 16] = [ - ("js", &["js", "mjs", "cjs", "jsx"]), - ("ts", &["ts", "tsx", "mts", "cts"]), - ("py", &["py", "pyi"]), - ("rust", &["rs"]), - ("go", &["go"]), - ("java", &["java"]), - ("c", &["c", "h"]), - ("cpp", &["cc", "cpp", "cxx", "hh", "hpp", "hxx", "h"]), - ("cs", &["cs"]), - ("rb", &["rb"]), - ("php", &["php"]), - ("md", &["md", "markdown"]), - ("json", &["json"]), - ("yaml", &["yaml", "yml"]), - ("toml", &["toml"]), - ("html", &["html", "htm"]), -]; - -/// The extensions of the type `name`, or `None` when no type has that -/// name. Names are case-sensitive. -pub(super) fn extensions(name: &str) -> Option<&'static [&'static str]> { - TYPES - .iter() - .find(|(known, _)| *known == name) - .map(|(_, extensions)| *extensions) -} - -/// The extension of `file_name`: the text after its last `.`, unless that -/// dot is the first character, so `a.js` has `js` and `.js` has none. -fn extension(file_name: &str) -> Option<&str> { - let dot = file_name.rfind('.')?; - if dot == 0 { - return None; - } - file_name.get(dot + 1..) -} - -/// Whether the file called `file_name` has one of `extensions`, compared -/// case-sensitively. -pub(super) fn matches(extensions: &[&str], file_name: &str) -> bool { - extension(file_name).is_some_and(|found| extensions.contains(&found)) -} - -#[cfg(test)] -mod tests; diff --git a/crates/plugin-std/src/grep/types/tests.rs b/crates/plugin-std/src/grep/types/tests.rs deleted file mode 100644 index b56734a35..000000000 --- a/crates/plugin-std/src/grep/types/tests.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Unit tests for the `type` table and the extension rule. - -use super::{extension, extensions, matches}; - -#[test] -fn the_extension_is_the_text_after_the_last_dot_unless_that_dot_is_first() { - assert_eq!(extension("a.js"), Some("js")); - assert_eq!(extension("a.b.js"), Some("js")); - assert_eq!(extension(".js"), None); - assert_eq!(extension("Makefile"), None); - assert_eq!(extension("x."), Some("")); -} - -#[test] -fn a_type_keeps_its_extensions_and_matching_is_case_sensitive() { - let rust = extensions("rust").expect("rust is a type"); - assert!(matches(rust, "lib.rs")); - assert!(!matches(rust, "lib.RS")); - assert!(!matches(rust, "rs")); - let cpp = extensions("cpp").expect("cpp is a type"); - assert!(matches(cpp, "a.h") && matches(cpp, "a.hpp")); - assert!(extensions("Rust").is_none()); - assert!(extensions("foo").is_none()); -} - -#[test] -fn every_documented_type_is_known() { - for name in [ - "js", "ts", "py", "rust", "go", "java", "c", "cpp", "cs", "rb", "php", "md", "json", - "yaml", "toml", "html", - ] { - assert!(extensions(name).is_some(), "{name}"); - } -} diff --git a/crates/plugin-std/src/grep/window.rs b/crates/plugin-std/src/grep/window.rs deleted file mode 100644 index 8ed11cb68..000000000 --- a/crates/plugin-std/src/grep/window.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! The two cuts Grep makes inside a line: a window around a match, and -//! the head of a context line. -//! -//! Both cuts go through `clip_to_boundary`, so neither splits a code -//! point, and both say how many bytes they left out, so a model can tell -//! that a line is not whole. - -use crate::text::clip_to_boundary; - -/// How much of a long line Grep shows. -#[derive(Debug, Clone, Copy)] -pub(super) struct Window { - /// The most bytes of a line shown: a longer line is cut. - pub(super) width: usize, - /// How many bytes before the first match a window starts. - pub(super) lead: usize, -} - -impl Default for Window { - /// 1,000 bytes, starting 200 bytes before the first match. - fn default() -> Window { - Window { - width: 1000, - lead: 200, - } - } -} - -/// The marker that says how many bytes of a line are not shown. -fn omitted(bytes: usize) -> String { - format!("[... {bytes} bytes omitted]") -} - -/// The text shown for a matching `line`, whose first match starts at byte -/// `first`. -/// -/// A line within the width is shown whole. A longer line becomes a window -/// that starts up to `lead` bytes before the match and holds at most -/// `width` bytes, with a marker directly before it when the window does -/// not start at the line's start and directly after it when the window -/// stops short of the line's end. The two counts plus the window's length -/// equal the line's length. -pub(super) fn match_text(line: &str, first: usize, window: &Window) -> String { - if line.len() <= window.width { - return line.to_owned(); - } - let start = first.saturating_sub(window.lead); - let shown = clip_to_boundary(line, start, start.saturating_add(window.width)); - // `clip_to_boundary` moves the start up to a character boundary, so the - // bytes before the window are counted from that same boundary. - let mut begin = start.min(line.len()); - while !line.is_char_boundary(begin) { - begin += 1; - } - let after = line.len() - begin - shown.len(); - let mut text = String::new(); - if begin > 0 { - text.push_str(&omitted(begin)); - } - text.push_str(shown); - if after > 0 { - text.push_str(&omitted(after)); - } - text -} - -/// The text shown for a context `line`: the line when it is within the -/// width, and otherwise its first `width` bytes followed by a marker that -/// counts the rest. -pub(super) fn context_text(line: &str, window: &Window) -> String { - if line.len() <= window.width { - return line.to_owned(); - } - let shown = clip_to_boundary(line, 0, window.width); - format!("{shown}{}", omitted(line.len() - shown.len())) -} diff --git a/crates/plugin-std/src/lib.rs b/crates/plugin-std/src/lib.rs deleted file mode 100644 index a9fe54071..000000000 --- a/crates/plugin-std/src/lib.rs +++ /dev/null @@ -1,113 +0,0 @@ -//! `plugin-std` - the Pack that gives a run the standard file tools a -//! coding agent needs: read, write, string replace, delete, glob, grep, -//! and list directory. -//! -//! The crate's Plugin, [`install`], inserts the Pack `runvil/std`, whose -//! Pack id is `std`, and a run then holds all of its tools unless it -//! declares another vendor's `std`: -//! -//! | Lua id | Name the model calls | -//! |---|---| -//! | `std/read` | `Read` | -//! | `std/write` | `Write` | -//! | `std/replace` | `StrReplace` | -//! | `std/rm` | `Delete` | -//! | `std/glob` | `Glob` | -//! | `std/grep` | `Grep` | -//! | `std/dir` | `ListDir` | -//! -//! A script calls a tool by its Lua id, `tools.call("std/read", { path = -//! "/notes.md" })`, and offers it to the model with `tools.offer("std")` -//! or `tools.offer("std/read")`. The model then calls it by its wire name, -//! `Read`, which is Cursor's name for the same tool. -//! -//! The Pack has no prelude, so declaring it, as in `uses: [std]`, only -//! makes it required. It takes no configuration but `null` or `{}`. -//! -//! # The definitions -//! -//! Each tool's description and parameter schema live in a JSON file under -//! `schemas/`, embedded at build time. They are Cursor's own -//! definitions, so a model that knows Cursor's tools already knows these, -//! with the lines that describe behavior this Pack does not have removed. -//! `schemas/EDITS.md` lists every such edit. ListDir's definition is this -//! crate's own, because Cursor's counterpart is deprecated, and the same -//! file says so. -//! -//! # This build -//! -//! Read is real. It returns a window of a text file's lines, each with a -//! six-wide line number, and it refuses a directory, a file over 16 MiB, -//! and binary data. A read that stops early ends with a line that names -//! the offset to continue from. File text comes back as untrusted output. -//! -//! ListDir is real. It returns a bullet tree of a directory, listed breadth -//! first, with a trailing `/` on each directory, files and directories in -//! one byte-ordered list, and every entry named `.git` skipped. A link is -//! listed and not entered, and an `ignore_globs` pattern leaves out what -//! it matches at any depth. The tree holds at most 15,000 bytes of lines, -//! and a listing that reaches that says so. Names come back as untrusted -//! output. -//! -//! Grep is real. It searches the files under a path with a Rust regular -//! expression, in path order, and prints each matching line as `N:text` -//! with optional context lines as `N-text`, where `N` is the line number -//! Read shows. Output stops at 15,000 bytes, a call with an `offset` -//! pages through the same result, and a binary file, a link, an over-size -//! file, and an entry that cannot be read are skipped and counted, never -//! a failure. A search yields to the executor now and then, so a cancel -//! or a stop can drop it. File text comes back as untrusted output. -//! -//! Write, StrReplace, Delete, and Glob are still stubs: a call with -//! arguments that match the schema answers [`NOT_IMPLEMENTED`]. For every -//! tool, a call whose arguments do not match fails with an -//! invalid-arguments [`Error`](runvil_plugin::Error) naming the -//! parameter. The stubs exist so that the crate, the definitions, the -//! ids, the wire names, and the Lua path can be proven before any file -//! logic exists. -//! -//! # Where the file access goes -//! -//! Every filesystem operation a tool performs goes through the -//! call's [`Access`](runvil_plugin::Access), never the disk directly. That -//! puts every read and write under the run's `ModePolicy`, so a run in -//! `Mode::Ask` refuses every mutation without the Pack deciding anything, -//! and a run in `Mode::Plan` allows mutations to markdown paths alone. The -//! tools only report the resulting `VfsError` as a tool error the model -//! can read. -//! -//! Shell is deliberately absent. An in-process shell over the same -//! filesystem is a separate Pack, so a run that wants one declares it. -//! -//! ## Invariants -//! -//! - May depend on: `runvil-plugin`, `shared-*` crates, and outside -//! libraries. `cargo test -p build-xtask` enforces the Plugin family's -//! allow-list. -//! - The crate names no async runtime and no filesystem of its own. Every -//! operation reaches files through the call's `Access`. -//! - A tool's description and parameter schema come from its JSON file -//! unchanged, except for the edits `schemas/EDITS.md` lists. A test per -//! tool holds the wire name in place. -//! - A call whose arguments the schema refuses is an -//! [`ToolInvalidArguments`](runvil_plugin::ErrorCode::ToolInvalidArguments) -//! failure that names the parameter. No other outcome reports the -//! arguments, so a model can tell a bad call from a missing -//! implementation. -//! - A key the schema does not declare is refused by name, with the -//! parameters listed, by the three real tools, Read, ListDir, and -//! Grep. The stubs let it through. Grep alone accepts `-n` when it is -//! `true`, because Cursor's definition once declared it. - -mod check; -mod dir; -mod grep; -mod matcher; -mod order; -mod pack; -mod read; -mod refusal; -mod spec; -mod text; - -pub use crate::pack::{NOT_IMPLEMENTED, install}; diff --git a/crates/plugin-std/src/matcher.rs b/crates/plugin-std/src/matcher.rs deleted file mode 100644 index 7bd8ad625..000000000 --- a/crates/plugin-std/src/matcher.rs +++ /dev/null @@ -1,224 +0,0 @@ -//! The glob matcher that ListDir's `ignore_globs` use, and that Grep's -//! `glob` filter uses after it. -//! -//! The grammar is the one `Access::glob` accepts, ported because this -//! crate cannot import the vfs matcher: only `*` and `**` are special, and -//! every other byte is literal and matched case-sensitively. One thing -//! differs. `Access::glob` anchors a pattern with no leading `/` at the -//! access root, and this matcher takes such a pattern at any depth, as if -//! `**/` came first, because it filters paths that are already known. -//! -//! The match is a bounded iterative sweep, with no recursion, so a hostile -//! pattern cannot drive exponential time or blow the stack. - -/// The longest pattern, in bytes, the matcher takes. -pub(crate) const MAX_PATTERN_BYTES: usize = 1024; - -/// Why a pattern was refused. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum PatternError { - /// The pattern is empty. - Empty, - /// The pattern is longer than [`MAX_PATTERN_BYTES`]. - TooLong, - /// The pattern holds a byte below 0x20, or 0x7f. - Control, - /// The pattern holds a backslash, and there is no escape syntax. - Backslash, - /// The pattern holds three or more `*` in a row. - StarRun, - /// A `**` that is not a whole path segment. - PartialDoubleStar, -} - -impl PatternError { - /// The text that says what rule the pattern broke. - pub(crate) fn reason(self) -> &'static str { - match self { - PatternError::Empty => "pattern is empty", - PatternError::TooLong => "pattern is longer than 1024 bytes", - PatternError::Control => "pattern contains a control character", - PatternError::Backslash => "pattern does not support backslash escapes", - PatternError::StarRun => "more than two consecutive '*' are not supported", - PatternError::PartialDoubleStar => "'**' must occupy a whole path segment", - } - } -} - -/// One unit of a valid pattern. -#[derive(Debug, Clone, Copy)] -enum Token { - /// A literal byte that must match exactly. - Literal(u8), - /// `*`: zero or more bytes, none of them `/`. - Star, - /// `**` at the end of the pattern, or alone: zero or more bytes of any - /// kind. - DoubleStar, - /// `**/`: zero or more whole path segments, which is nothing, or a run - /// that ends in `/`. - DoubleStarSlash, -} - -/// A valid pattern, ready to match absolute virtual paths. -#[derive(Debug)] -pub(crate) struct Matcher { - /// The pattern's units, with a leading [`Token::DoubleStarSlash`] when - /// the pattern has no leading `/`. - tokens: Vec, -} - -impl Matcher { - /// Compiles `pattern`. - /// - /// A pattern with a leading `/` is a full path from the root. Any - /// other pattern matches at any depth, as if `**/` came first. - /// - /// # Errors - /// Returns the rule the pattern breaks: empty, longer than - /// [`MAX_PATTERN_BYTES`], holding a control byte or a backslash, - /// holding more than two `*` in a row, or holding a `**` that is not a - /// whole path segment. The rules are checked in that order. - pub(crate) fn new(pattern: &str) -> Result { - validate(pattern)?; - let mut tokens = Vec::with_capacity(pattern.len() + 1); - if !pattern.starts_with('/') { - tokens.push(Token::DoubleStarSlash); - } - tokenize(pattern.as_bytes(), &mut tokens); - Ok(Matcher { tokens }) - } - - /// Whether the whole of `path`, an absolute virtual path with a leading - /// `/`, matches. - /// - /// The sweep tracks which prefixes of `path` the tokens read so far can - /// consume, so it takes `O(tokens * path length)` time. - pub(crate) fn is_match(&self, path: &str) -> bool { - let text = path.as_bytes(); - let len = text.len(); - // `reachable[j]` is true when the tokens read so far match exactly - // `text[..j]`. - let mut reachable = vec![false; len + 1]; - if let Some(start) = reachable.first_mut() { - *start = true; - } - let mut next = vec![false; len + 1]; - for token in &self.tokens { - next.fill(false); - match *token { - Token::Literal(byte) => { - for (j, here) in reachable.iter().take(len).enumerate() { - if *here - && text.get(j) == Some(&byte) - && let Some(after) = next.get_mut(j + 1) - { - *after = true; - } - } - } - Token::Star => { - // Zero or more bytes that are not `/`: carry - // reachability forward across each such byte. - let mut carry = false; - for (j, (slot, here)) in next.iter_mut().zip(&reachable).enumerate() { - let reached = *here || carry; - *slot = reached; - carry = reached && text.get(j).is_some_and(|byte| *byte != b'/'); - } - } - Token::DoubleStar => { - // Zero or more bytes of any kind: once a position is - // reachable, every later one is too. - let mut seen = false; - for (slot, here) in next.iter_mut().zip(&reachable) { - seen |= *here; - *slot = seen; - } - } - Token::DoubleStarSlash => { - // Nothing, or a run that ends in `/`. - let mut seen = false; - for (j, (slot, here)) in next.iter_mut().zip(&reachable).enumerate() { - let after_slash = j - .checked_sub(1) - .and_then(|before| text.get(before)) - .is_some_and(|byte| *byte == b'/'); - *slot = *here || (seen && after_slash); - seen |= *here; - } - } - } - std::mem::swap(&mut reachable, &mut next); - } - reachable.last().copied().unwrap_or(false) - } -} - -/// Checks `pattern` against the rules, in a fixed order: empty, length, -/// control bytes, backslash, then the `*` grammar. -fn validate(pattern: &str) -> Result<(), PatternError> { - if pattern.is_empty() { - return Err(PatternError::Empty); - } - if pattern.len() > MAX_PATTERN_BYTES { - return Err(PatternError::TooLong); - } - if pattern.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { - return Err(PatternError::Control); - } - if pattern.contains('\\') { - return Err(PatternError::Backslash); - } - let bytes = pattern.as_bytes(); - let mut index = 0; - while index < bytes.len() { - if bytes.get(index) != Some(&b'*') { - index += 1; - continue; - } - let run_start = index; - while bytes.get(index) == Some(&b'*') { - index += 1; - } - match index - run_start { - 1 => {} - 2 => { - let before_ok = run_start - .checked_sub(1) - .is_none_or(|before| bytes.get(before) == Some(&b'/')); - let after_ok = bytes.get(index).is_none_or(|byte| *byte == b'/'); - if !(before_ok && after_ok) { - return Err(PatternError::PartialDoubleStar); - } - } - _ => return Err(PatternError::StarRun), - } - } - Ok(()) -} - -/// Turns the valid pattern `pattern` into tokens, pushed onto `tokens`. -fn tokenize(pattern: &[u8], tokens: &mut Vec) { - let mut index = 0; - while let Some(byte) = pattern.get(index) { - if *byte != b'*' { - tokens.push(Token::Literal(*byte)); - index += 1; - } else if pattern.get(index + 1) == Some(&b'*') { - if pattern.get(index + 2) == Some(&b'/') { - tokens.push(Token::DoubleStarSlash); - index += 3; - } else { - tokens.push(Token::DoubleStar); - index += 2; - } - } else { - tokens.push(Token::Star); - index += 1; - } - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/plugin-std/src/matcher/tests.rs b/crates/plugin-std/src/matcher/tests.rs deleted file mode 100644 index cffce8daa..000000000 --- a/crates/plugin-std/src/matcher/tests.rs +++ /dev/null @@ -1,275 +0,0 @@ -//! Unit tests for the glob matcher: each grammar rule, the refusals with -//! their reason texts, and the cases it shares with `Access::glob`, which -//! are pinned against that function's own tables. - -use runvil_plugin::ToolId; -use runvil_plugin::testing::TestCall; - -use super::{MAX_PATTERN_BYTES, Matcher, PatternError}; - -/// A matcher for `pattern`, which must be valid. -fn matcher(pattern: &str) -> Matcher { - Matcher::new(pattern).expect("the pattern is valid") -} - -/// Whether the valid `pattern` matches the absolute virtual `path`. -fn matches(pattern: &str, path: &str) -> bool { - matcher(pattern).is_match(path) -} - -/// The reason text of the refusal of `pattern`. -fn reason_of(pattern: &str) -> &'static str { - match Matcher::new(pattern) { - Ok(_) => panic!("{pattern:?} is refused"), - Err(error) => error.reason(), - } -} - -#[test] -fn a_star_matches_zero_or_more_bytes_inside_one_segment() { - assert!(matches("/a/*.rs", "/a/b.rs")); - assert!(matches("/a/*.rs", "/a/.rs")); - assert!(matches("/a/b*", "/a/b")); - assert!(!matches("/a/*.rs", "/a/x/b.rs")); - assert!(!matches("/a/*", "/a/x/y")); -} - -#[test] -fn a_star_matches_dotfiles() { - assert!(matches("/*", "/.env")); - assert!(matches("/src/*", "/src/.hidden")); - assert!(matches("*.log", "/.debug.log")); - assert!(matches("/.*", "/.gitignore")); -} - -#[test] -fn a_double_star_segment_matches_zero_or_more_whole_segments() { - assert!(matches("/a/**/z", "/a/z")); - assert!(matches("/a/**/z", "/a/b/z")); - assert!(matches("/a/**/z", "/a/b/c/z")); - assert!(!matches("/a/**/z", "/a/bz")); - assert!(!matches("/a/**/z", "/b/z")); -} - -#[test] -fn a_trailing_double_star_matches_everything_below() { - assert!(matches("/a/**", "/a/b")); - assert!(matches("/a/**", "/a/b/c/d")); - assert!(!matches("/a/**", "/b/c")); -} - -#[test] -fn every_byte_but_the_star_is_literal() { - assert!(matches("/a?", "/a?")); - assert!(!matches("/a?", "/ab")); - assert!(matches("/[ab]", "/[ab]")); - assert!(!matches("/[ab]", "/a")); - assert!(matches("/{a,b}", "/{a,b}")); - assert!(!matches("/{a,b}", "/a")); - assert!(matches("/!x", "/!x")); - assert!(matches("/a.rs", "/a.rs")); - assert!(!matches("/a.rs", "/aXrs")); -} - -#[test] -fn matching_is_case_sensitive() { - assert!(matches("/README.md", "/README.md")); - assert!(!matches("/README.md", "/readme.md")); - assert!(!matches("*.TXT", "/a.txt")); -} - -#[test] -fn a_pattern_without_a_leading_slash_matches_at_any_depth() { - assert!(matches("node_modules", "/node_modules")); - assert!(matches("node_modules", "/proj/node_modules")); - assert!(matches("node_modules", "/a/b/c/node_modules")); - assert!(matches("*.log", "/x.log")); - assert!(matches("*.log", "/a/b/x.log")); - assert!(matches("src/*.rs", "/proj/src/a.rs")); -} - -#[test] -fn an_any_depth_pattern_still_matches_a_whole_segment_run() { - assert!(!matches("node_modules", "/proj/my_node_modules")); - assert!(!matches("node_modules", "/proj/node_modules_old")); - assert!(!matches("src/*.rs", "/proj/mysrc/a.rs")); - assert!(!matches("*.log", "/a.log/b")); -} - -#[test] -fn a_pattern_with_a_leading_slash_is_a_full_path_from_the_root() { - assert!(matches("/build", "/build")); - assert!(!matches("/build", "/proj/build")); - assert!(matches("/proj/build", "/proj/build")); - assert!(!matches("/proj/build", "/other/proj/build")); - assert!(matches("/", "/")); - assert!(!matches("/", "/a")); -} - -#[test] -fn a_leading_double_star_matches_with_or_without_the_slash_form() { - assert!(matches("**/*.md", "/notes/today.md")); - assert!(matches("**/*.md", "/today.md")); - assert!(matches("/**/*.md", "/today.md")); - assert!(matches("/**/*.md", "/a/b/today.md")); -} - -#[test] -fn every_refusal_has_its_reason_text() { - assert_eq!(reason_of(""), "pattern is empty"); - assert_eq!( - reason_of(&"a".repeat(MAX_PATTERN_BYTES + 1)), - "pattern is longer than 1024 bytes" - ); - assert_eq!(reason_of("a\u{0}b"), "pattern contains a control character"); - assert_eq!( - reason_of("a\u{1f}b"), - "pattern contains a control character" - ); - assert_eq!( - reason_of("a\u{7f}b"), - "pattern contains a control character" - ); - assert_eq!( - reason_of("a\\b"), - "pattern does not support backslash escapes" - ); - assert_eq!( - reason_of("a***"), - "more than two consecutive '*' are not supported" - ); - assert_eq!( - reason_of("a/***/b"), - "more than two consecutive '*' are not supported" - ); - assert_eq!(reason_of("a/**b"), "'**' must occupy a whole path segment"); - assert_eq!(reason_of("a**"), "'**' must occupy a whole path segment"); - assert_eq!(reason_of("**a/b"), "'**' must occupy a whole path segment"); -} - -#[test] -fn the_longest_allowed_pattern_is_1024_bytes() { - assert_eq!(MAX_PATTERN_BYTES, 1024); - assert!(Matcher::new(&"a".repeat(1024)).is_ok()); - assert_eq!( - Matcher::new(&"a".repeat(1025)).err(), - Some(PatternError::TooLong) - ); -} - -#[test] -fn a_refusal_is_checked_in_a_fixed_order() { - // Too long wins over a control byte, and a control byte over a - // backslash, so the same bad pattern always hears the same reason. - let long_and_control = format!("{}\u{0}", "a".repeat(MAX_PATTERN_BYTES)); - assert_eq!( - reason_of(&long_and_control), - "pattern is longer than 1024 bytes" - ); - assert_eq!(reason_of("\u{0}\\"), "pattern contains a control character"); - assert_eq!( - reason_of("\\***"), - "pattern does not support backslash escapes" - ); -} - -#[test] -fn the_validation_table_matches_the_one_access_glob_pins() { - // The cases `glob.rs` pins in the vfs crate: the over-long, control, - // backslash, and wildcard refusals, and one pattern that is fine. - let over_long = "a".repeat(MAX_PATTERN_BYTES + 1); - for (pattern, refused) in [ - (over_long.as_str(), true), - ("a\u{0}b", true), - ("a\\b", true), - ("a/***/b", true), - ("a/**b", true), - ("ok/*.txt", false), - ] { - assert_eq!( - Matcher::new(pattern).is_err(), - refused, - "{pattern:?} is refused: {refused}" - ); - } -} - -#[test] -fn the_match_table_is_the_one_access_glob_pins_when_anchored_with_a_leading_slash() { - // The cases `glob.rs` pins, run anchored: each pattern and each path - // gets a leading `/`, so a pattern is a full path from the root, as it - // is for `Access::glob`. - let paths = [ - "a.txt", - "src/a.rs", - "src/b.rs", - "src/deep/c.rs", - "src/deep/deeper/d.rs", - "notes/today.md", - ]; - for (pattern, expected) in [ - ("*.txt", [true, false, false, false, false, false]), - ("src/*.rs", [false, true, true, false, false, false]), - ("src/**/*.rs", [false, true, true, true, true, false]), - ("**/*.md", [false, false, false, false, false, true]), - ("src/**", [false, true, true, true, true, false]), - ("no*match", [false, false, false, false, false, false]), - ] { - let matcher = matcher(&format!("/{pattern}")); - for (path, expected) in paths.iter().zip(expected) { - assert_eq!( - matcher.is_match(&format!("/{path}")), - expected, - "pattern /{pattern} on /{path}" - ); - } - } -} - -#[test] -fn an_unanchored_pattern_is_the_one_way_matching_differs_from_access_glob() { - // `Access::glob("*.txt")` is anchored at the access root, so `*` never - // crosses a `/` and only the top-level file comes back. The matcher - // takes a pattern with no leading `/` at any depth. - let call = TestCall::new(ToolId::parse("std/dir").expect("a two-segment tool id parses")); - let access = call.context().access(); - access.write("/a.txt", b"").expect("the file is written"); - access - .write("/x/y/a.txt", b"") - .expect("the file is written"); - let globbed = access.glob("*.txt").expect("the pattern is valid"); - assert_eq!(globbed, ["a.txt"]); - let any_depth = matcher("*.txt"); - assert!(any_depth.is_match("/a.txt")); - assert!(any_depth.is_match("/x/y/a.txt")); -} - -#[test] -fn a_long_text_does_not_blow_the_stack_or_the_clock() { - // The match is an iterative sweep, so a deep path and a pattern full - // of stars finish at once. - let deep = format!("/{}", "d/".repeat(5_000)); - assert!(matches("**/**/**/x", &format!("{deep}x"))); - assert!(!matches("**/**/**/x", &format!("{deep}y"))); - assert!(matches("*a*a*a*a*b", &format!("/{}b", "a".repeat(2_000)))); - assert!(!matches("*a*a*a*a*b", &format!("/{}", "a".repeat(2_000)))); -} - -#[test] -fn the_reason_texts_are_distinct() { - let reasons = [ - PatternError::Empty, - PatternError::TooLong, - PatternError::Control, - PatternError::Backslash, - PatternError::StarRun, - PatternError::PartialDoubleStar, - ] - .map(PatternError::reason); - for (index, reason) in reasons.iter().enumerate() { - assert!( - !reasons.iter().skip(index + 1).any(|other| other == reason), - "{reason} appears twice" - ); - } -} diff --git a/crates/plugin-std/src/order.rs b/crates/plugin-std/src/order.rs deleted file mode 100644 index 0d3c85fdb..000000000 --- a/crates/plugin-std/src/order.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! The order and the one name the directory-walking tools share. -//! -//! ListDir and Grep both walk a directory tree, and the order they visit -//! entries in is a promise to the model: it is the order `Access::glob` -//! gives, so a walk and a glob over the same tree list the same paths in -//! the same sequence. Both tools take the order and the skipped name from -//! here, so the two cannot drift apart. - -use runvil_plugin::{Entry, FileType}; - -/// The name of the entry a walk skips, a file or a directory, at any -/// depth. -pub(crate) const GIT: &str = ".git"; - -/// `entries` in the order a walk visits them: by name, with `/` added to -/// a directory's name. -/// -/// `Access::list` promises no order. This is the order `Access::glob` -/// gives, which sorts full path strings bytewise. -pub(crate) fn sorted(mut entries: Vec) -> Vec { - entries.sort_by_cached_key(|entry| { - if matches!(entry.stat.file_type, FileType::Directory) { - format!("{}/", entry.name) - } else { - entry.name.clone() - } - }); - entries -} - -#[cfg(test)] -mod tests; diff --git a/crates/plugin-std/src/order/tests.rs b/crates/plugin-std/src/order/tests.rs deleted file mode 100644 index 1eb5b2fad..000000000 --- a/crates/plugin-std/src/order/tests.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! Unit tests for the shared walk order: the one sequence ListDir and -//! Grep both visit entries in. - -use runvil_plugin::{Entry, FileType, Stat}; - -use super::{GIT, sorted}; - -/// An entry called `name` of the given kind. -fn entry(name: &str, file_type: FileType) -> Entry { - Entry::new(name, Stat::new(file_type, 0)) -} - -/// The names of `entries`, in order. -fn names(entries: &[Entry]) -> Vec<&str> { - entries.iter().map(|entry| entry.name.as_str()).collect() -} - -#[test] -fn a_directory_sorts_as_its_name_with_a_slash_added() { - let entries = vec![ - entry("b", FileType::File), - entry("a.txt", FileType::File), - entry("a", FileType::Directory), - entry("a-b", FileType::File), - entry("B", FileType::File), - ]; - // Full paths `/B`, `/a-b`, `/a.txt`, `/a/x`, `/b` sort bytewise in - // this order: `-` is below `.`, which is below `/`. - assert_eq!(names(&sorted(entries)), ["B", "a-b", "a.txt", "a", "b"]); -} - -#[test] -fn a_directory_and_a_file_that_share_a_prefix_follow_the_path_order() { - let entries = vec![ - entry("x.rs", FileType::File), - entry("x", FileType::Directory), - entry("x-y", FileType::File), - ]; - assert_eq!(names(&sorted(entries)), ["x-y", "x.rs", "x"]); -} - -#[test] -fn the_order_does_not_depend_on_the_order_listed() { - let forward = vec![ - entry("a", FileType::Directory), - entry("a.txt", FileType::File), - entry("b", FileType::File), - ]; - let backward: Vec = forward.iter().rev().cloned().collect(); - assert_eq!(names(&sorted(forward)), names(&sorted(backward))); -} - -#[test] -fn the_skipped_name_is_dot_git() { - assert_eq!(GIT, ".git"); -} diff --git a/crates/plugin-std/src/pack-tests.rs b/crates/plugin-std/src/pack-tests.rs deleted file mode 100644 index 76651bcf8..000000000 --- a/crates/plugin-std/src/pack-tests.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! Unit tests for the `std` Pack's stub text and the definitions it -//! builds from. - -use super::{NOT_IMPLEMENTED, STD}; -use crate::spec; - -#[test] -fn the_pack_is_runvil_std_with_no_prelude() { - assert_eq!(STD.fqn, "runvil/std"); - assert!(STD.prelude.is_none()); -} - -#[test] -fn the_stub_names_the_tool_in_every_place_the_text_mentions_it() { - let text = NOT_IMPLEMENTED.replace("", "Grep"); - assert_eq!( - text.matches("Grep").count(), - 2, - "the text names the tool twice: {text}" - ); - assert!(!text.contains(""), "{text}"); -} - -#[test] -fn the_stub_says_the_implementation_is_missing_and_the_result_is_stable() { - let text = NOT_IMPLEMENTED.replace("", "Write"); - for phrase in [ - "not implemented in this build", - "The implementation is missing", - "nothing in this session will change that", - "Calling Write again is safe", - "this same result every time", - "Carry on without it.", - ] { - assert!(text.contains(phrase), "missing {phrase:?}: {text}"); - } -} - -#[test] -fn the_stub_says_what_is_missing_before_it_says_anything_else() { - let text = NOT_IMPLEMENTED.replace("", "Read"); - assert!( - text.starts_with("The Read tool is not implemented"), - "the first clause says what is missing: {text}" - ); -} - -#[test] -fn the_specs_are_the_seven_the_crate_names_in_order() { - let segments: Vec<&str> = spec::all() - .expect("every embedded definition parses") - .iter() - .map(|definition| definition.segment) - .collect(); - assert_eq!( - segments, - ["read", "write", "replace", "rm", "glob", "grep", "dir"] - ); -} diff --git a/crates/plugin-std/src/pack.rs b/crates/plugin-std/src/pack.rs deleted file mode 100644 index 66cf07cc8..000000000 --- a/crates/plugin-std/src/pack.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! The crate's Plugin, [`install`], the `std` Pack's `PackInfo`, the -//! factory that makes each run's instance, and the stub that every tool -//! but Read, ListDir, and Grep still answers with in this build. - -use std::sync::Arc; - -use runvil_plugin::{ - BoxFuture, Error, ErrorCode, HostServices, Pack, PackId, PackInfo, PackRegistry, ToolContext, - ToolId, ToolInfo, ToolOutput, make_tool_error, -}; -use serde_json::{Value, json}; - -use crate::spec::{self, Definition}; - -/// What a call to a tool that is still a stub answers in this build, when -/// its arguments match the schema. Read, ListDir, and Grep are real and -/// never answer this. -/// -/// The text says the implementation is missing rather than that the call -/// was wrong, because a model that reads it should stop calling the tool. -/// It also says a repeat call is safe, so a model that retries anyway -/// learns the outcome is the same rather than assuming the call was -/// malformed. `` is the tool's wire name. -pub const NOT_IMPLEMENTED: &str = "The tool is not implemented in this build. The \ - implementation is missing and nothing in this session will change that. Calling again \ - is safe, but it will return this same result every time, whatever the arguments. Carry on \ - without it."; - -/// The Pack's `PackInfo`. -/// -/// Its FQN is `runvil/std`, so its Pack id is `std`. It has no prelude, -/// and it takes no configuration but `null` or `{}`. -const STD: PackInfo = - PackInfo::new("runvil/std").description("Read, write, search, and delete files"); - -/// The Plugin: inserts the `std` Pack into `packs`. Its config, -/// `config["std"]`, which is `null` when `config` has no `std` key, must -/// be `null` or `{}`; anything else inserts the Pack unavailable with -/// "std takes no configuration". It reads none of the Host-wide -/// `services`. -/// -/// # Errors -/// Returns the registry's refusal, such as an -/// [`InvalidValue`](runvil_plugin::ErrorCode::InvalidValue) error with the -/// reason `duplicate_pack` when `packs` holds `runvil/std` already. -#[expect( - clippy::needless_pass_by_value, - reason = "the Plugin signature fixes the argument types" -)] -pub fn install( - packs: &mut PackRegistry, - config: Value, - _services: &HostServices, -) -> Result<(), Error> { - let config = &config["std"]; - if !(config.is_null() || *config == json!({})) { - return packs.insert_unavailable( - STD, - make_tool_error(ErrorCode::ToolOther, "std takes no configuration"), - ); - } - packs.insert(STD, new_pack) -} - -/// One run's instance: its tool list. -#[derive(Debug)] -struct Std { - tools: Vec, -} - -/// The Pack's factory: makes the Pack under the Pack id `name`, naming -/// each tool `/`. -fn new_pack(name: &PackId) -> Result, Error> { - let tools = spec::all()? - .iter() - .map(|definition| { - let id = ToolId::parse(&format!("{name}/{}", definition.segment)).map_err(|e| { - make_tool_error( - ErrorCode::ToolOther, - format!("std could not name its {} tool", definition.segment), - ) - .with_cause(&e) - })?; - let info = ToolInfo::new( - id, - definition.description.clone(), - definition.parameters.clone(), - ) - .wire_name(definition.wire_name.clone()); - Ok(info) - }) - .collect::, Error>>()?; - Ok(Arc::new(Std { tools })) -} - -/// The stub text for the tool the model calls `wire_name`. -fn stub(wire_name: &str) -> String { - NOT_IMPLEMENTED.replace("", wire_name) -} - -/// The definition of the tool whose id ends in `segment`. -fn definition(segment: &str) -> Option<&'static Definition> { - spec::all() - .ok()? - .iter() - .find(|definition| definition.segment == segment) -} - -impl Pack for Std { - fn tools(&self) -> Vec { - self.tools.clone() - } - - fn call<'a>( - &'a self, - cx: ToolContext<'a>, - args: Value, - ) -> BoxFuture<'a, Result> { - Box::pin(async move { - let name = cx.tool().name(); - let Some(definition) = definition(name) else { - return Err(make_tool_error( - ErrorCode::ToolOther, - format!("std has no tool named {name}"), - )); - }; - crate::check::check(&definition.wire_name, &definition.parameters, &args)?; - match definition.segment { - "read" => crate::read::call(cx.access(), definition, &args), - "dir" => crate::dir::call(cx.access(), definition, &args), - "grep" => crate::grep::call(cx.access(), definition, &args).await, - _ => Ok(ToolOutput::trusted(stub(&definition.wire_name))), - } - }) - } -} - -#[cfg(test)] -#[path = "pack-tests.rs"] -mod tests; diff --git a/crates/plugin-std/src/read.rs b/crates/plugin-std/src/read.rs deleted file mode 100644 index 71a00d636..000000000 --- a/crates/plugin-std/src/read.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! The `Read` tool: a window of a text file's lines, numbered. -//! -//! Read opens the path the call names through the call's [`Access`], so -//! the run's mode and policy apply. It refuses a directory, a special -//! file, a file over 16 MiB, and binary data, and it never truncates a -//! file: a read that does not reach the last line says which offset -//! continues it. File text is returned as untrusted output. - -mod args; -mod render; -mod window; - -#[cfg(test)] -mod tests; - -use runvil_plugin::{Access, Error, ErrorCode, FileType, ToolOutput, VfsError, make_tool_error}; -use serde_json::Value; - -use crate::check::plural; -use crate::refusal::{neither_file_nor_directory, refusal}; -use crate::spec::Definition; -use crate::text::{MAX_FILE_BYTES, decode, sniff_binary, split_lines}; - -use self::args::Request; - -/// The most bytes of numbered lines one read returns: each line's prefix, -/// its text, and its newline count, and the notes and the trailer do not. -pub(crate) const BYTE_CAP: usize = 100_000; - -/// Runs one `Read` call whose arguments already passed the shared schema -/// check. -/// -/// # Errors -/// Returns an invalid-arguments [`Error`] naming the parameter for a -/// bad argument, and a message error for any other failure, worded for a -/// model to read. -pub(crate) fn call( - access: &Access, - definition: &Definition, - args: &Value, -) -> Result { - let request = args::parse(definition, args)?; - read_file(access, &definition.wire_name, &request, BYTE_CAP) -} - -/// Reads the file `request` names, in the window it asks for, with at -/// most `cap` bytes of numbered lines. `tool` is the name the model -/// called the tool by. -fn read_file( - access: &Access, - tool: &str, - request: &Request<'_>, - cap: usize, -) -> Result { - let path = request.path; - let message = |text: String| make_tool_error(ErrorCode::ToolOther, text); - let failed = |error: VfsError| message(refusal(tool, path, &error)); - let stat = access.stat(path).map_err(failed)?; - check_type(path, stat.file_type).map_err(message)?; - let bytes = access - .read_range(path, 0, MAX_FILE_BYTES + 1) - .map_err(failed)?; - if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_FILE_BYTES { - return Err(message(format!( - "File is too large to read: {path} is over the limit of {MAX_FILE_BYTES} bytes." - ))); - } - if sniff_binary(&bytes) { - return Err(message(format!( - "File content is binary data, not text: {path}" - ))); - } - let decoded = decode(&bytes); - let lines = split_lines(&decoded.text); - if lines.is_empty() { - return Ok(ToolOutput::untrusted("File is empty.")); - } - let Some(window) = window::window(lines.len(), request.offset, request.limit) else { - return Err(message(format!( - "Offset {} is beyond the end of {path}, which has {}.", - request.offset.unwrap_or_default(), - plural(lines.len(), "line") - ))); - }; - let notes = render::notes(decoded.utf16, decoded.invalid); - Ok(ToolOutput::untrusted(render::render( - &lines, &window, ¬es, cap, - ))) -} - -/// Decides from the kind of node at `path` whether Read goes on: a file -/// or a link does, and anything else gets the sentence that says why not. -fn check_type(path: &str, file_type: FileType) -> Result<(), String> { - match file_type { - FileType::File | FileType::Symlink => Ok(()), - FileType::Directory => Err(format!("Path is a directory, not a file: {path}")), - // A pipe, a socket, or a device, and any kind added later: none - // of them is text Read can window. - _ => Err(neither_file_nor_directory(path)), - } -} diff --git a/crates/plugin-std/src/read/args.rs b/crates/plugin-std/src/read/args.rs deleted file mode 100644 index 63a49cd51..000000000 --- a/crates/plugin-std/src/read/args.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Read's arguments: what the call asks for, and what is refused before -//! any file is touched. - -use runvil_plugin::Error; -use serde_json::Value; - -use crate::check::{make_invalid_arguments_error, refuse_blank, refuse_unknown}; -use crate::spec::Definition; - -/// What a call asks Read for. -#[derive(Debug)] -pub(super) struct Request<'a> { - /// The path as the model wrote it. - pub(super) path: &'a str, - /// The line to start at: `0` and an absent offset read as 1, and a - /// negative one counts back from the end. - pub(super) offset: Option, - /// The most lines to read, at least 1. - pub(super) limit: Option, -} - -/// Reads the call's arguments after the shared schema check. -/// -/// The order is fixed: an undeclared key, then a blank path, then the -/// offset, then the limit, so a call that breaks two rules always hears -/// about the same one. -/// -/// # Errors -/// Returns an invalid-arguments [`Error`] naming the parameter. -pub(super) fn parse<'a>(definition: &Definition, args: &'a Value) -> Result, Error> { - let tool = definition.wire_name.as_str(); - refuse_unknown(tool, &definition.parameters, args)?; - let path = args.get("path").and_then(Value::as_str).unwrap_or_default(); - refuse_blank(tool, "path", path)?; - let offset = integer(tool, "offset", args)?.map(|(offset, _)| offset); - let limit = match integer(tool, "limit", args)? { - Some((limit, given)) if limit < 1 => { - return Err(make_invalid_arguments_error( - tool, - format!("`limit` must be at least 1, and the call passed {given}"), - )); - } - Some((limit, _)) => Some(usize::try_from(limit).unwrap_or(usize::MAX)), - None => None, - }; - Ok(Request { - path, - offset, - limit, - }) -} - -/// The integer under `name` in `args` with the value as the call wrote -/// it, or `None` when the call left it out or passed `null`. -/// -/// The shared check has already refused anything that is not a whole -/// number, so a value that still has no `i64` is a whole number past the -/// 64-bit range, which is refused here. -fn integer<'a>(tool: &str, name: &str, args: &'a Value) -> Result, Error> { - let Some(value) = args.get(name).filter(|value| !value.is_null()) else { - return Ok(None); - }; - whole_number(value) - .map(|number| Some((number, value))) - .ok_or_else(|| { - make_invalid_arguments_error( - tool, - format!( - "`{name}` must be a JSON integer that fits in 64 bits, \ - and the call passed {value}" - ), - ) - }) -} - -/// The `i64` a JSON number holds, counting a whole number written with a -/// fraction part such as `3.0`, which the shared check accepts as an -/// integer. `None` for a number outside the `i64` range. -fn whole_number(value: &Value) -> Option { - if let Some(integer) = value.as_i64() { - return Some(integer); - } - let float = value.as_f64()?; - if float.fract() != 0.0 || !(-I64_BOUND..I64_BOUND).contains(&float) { - return None; - } - #[expect( - clippy::cast_possible_truncation, - reason = "the value is whole and inside the i64 range, so the cast is exact" - )] - Some(float as i64) -} - -/// 2^63, the first `f64` above the `i64` range (`-2^63` is `i64::MIN`). -const I64_BOUND: f64 = 9_223_372_036_854_775_808.0; diff --git a/crates/plugin-std/src/read/render.rs b/crates/plugin-std/src/read/render.rs deleted file mode 100644 index 87ba6eb1d..000000000 --- a/crates/plugin-std/src/read/render.rs +++ /dev/null @@ -1,118 +0,0 @@ -//! The render: turns a window of lines into the text Read answers with. -//! -//! Each line prints as `{number:>6}|{text}`: a six-wide right-aligned -//! number, a pipe, and no space. The number is always on and widens past -//! six digits, and it is not part of the line. The numbered lines are -//! followed by the notes and then the trailer, each on its own line, with -//! no newline after the last one. - -use crate::check::plural; -use crate::text::clip_to_boundary; - -use super::window::Window; - -/// The numbered prefix of line `number`. -fn prefix(number: usize) -> String { - format!("{number:>6}|") -} - -/// The text of the `window` over `lines`, with `notes` and a trailer when -/// the read stops early. -/// -/// The numbered lines hold at most `cap` bytes, counting each prefix, the -/// text, and its newline, and not the notes or the trailer. A line that -/// would pass the cap is left out, and the trailer names the offset that -/// continues. When the window's first line alone passes the cap, it is -/// cut to fit and printed alone, and the trailer says so: this is the only -/// cut inside a line. A read that reaches the last line prints no trailer. -/// -/// `window` lies inside `lines`. -pub(super) fn render(lines: &[&str], window: &Window, notes: &[String], cap: usize) -> String { - let total = lines.len(); - let mut body = String::new(); - let mut used = 0usize; - let mut shown = 0usize; - for number in window.first..=window.last { - let Some(text) = lines.get(number - 1) else { - break; - }; - let prefix = prefix(number); - let cost = prefix.len() + text.len() + 1; - if used + cost > cap { - break; - } - body.push_str(&prefix); - body.push_str(text); - body.push('\n'); - used += cost; - shown = number; - } - let trailer = if shown == 0 { - Some(cut_first_line(lines, window.first, cap, &mut body)) - } else if shown < window.last { - Some(format!( - "[Showing lines {first}-{shown} of {total} ({cap}-byte limit). \ - Use offset={next} to continue.]", - first = window.first, - next = shown + 1, - )) - } else if window.last < total { - Some(format!( - "[Showing lines {first}-{last} of {total}. Use offset={next} to continue.]", - first = window.first, - last = window.last, - next = window.last + 1, - )) - } else { - None - }; - let tail: Vec<&str> = notes - .iter() - .map(String::as_str) - .chain(trailer.as_deref()) - .collect(); - body.push_str(&tail.join("\n")); - body -} - -/// Prints the first line of the window, number `number`, cut to fit -/// `cap` bytes, into `body`, and returns the trailer that says it was -/// cut. -fn cut_first_line(lines: &[&str], number: usize, cap: usize, body: &mut String) -> String { - let text = lines.get(number - 1).copied().unwrap_or_default(); - let prefix = prefix(number); - let allowance = cap.saturating_sub(prefix.len() + 1); - let kept = clip_to_boundary(text, 0, allowance); - body.push_str(&prefix); - body.push_str(kept); - body.push('\n'); - let next = if number < lines.len() { - format!(" Use offset={} to continue.", number + 1) - } else { - String::new() - }; - format!( - "[Line {number} is {bytes} bytes and is cut after {cut} bytes. \ - No parameter reads the rest of a line.{next}]", - bytes = text.len(), - cut = kept.len(), - ) -} - -/// The notes for a file that was decoded, in the order they print: the -/// UTF-16 note first, then the note that counts replaced bytes. -pub(super) fn notes(utf16: Option<&str>, invalid: usize) -> Vec { - let mut notes = Vec::new(); - if let Some(name) = utf16 { - notes.push(format!( - "[Note: the file is {name} and was decoded to text.]" - )); - } - if invalid > 0 { - notes.push(format!( - "[Note: {} in this file were shown as U+FFFD.]", - plural(invalid, "invalid UTF-8 sequence") - )); - } - notes -} diff --git a/crates/plugin-std/src/read/tests.rs b/crates/plugin-std/src/read/tests.rs deleted file mode 100644 index 27d2b8b03..000000000 --- a/crates/plugin-std/src/read/tests.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! Unit tests for the `Read` tool. This file holds the helpers the topic -//! files share: a call over a fresh in-memory filesystem, a seeded file, -//! and the two ways to drive Read, through its arguments and through a -//! lowered byte cap. - -mod arguments; -mod failures; -mod paging; -mod render; -mod window; - -use runvil_plugin::testing::TestCall; -use runvil_plugin::{Error, OutputTrust, ToolId, ToolOutput}; -use serde_json::Value; - -use super::args::Request; -use crate::spec::{self, Definition}; - -/// A call of `std/read` over a fresh in-memory filesystem. -fn new_call() -> TestCall { - TestCall::new(ToolId::parse("std/read").expect("a two-segment tool id parses")) -} - -/// Writes `bytes` to `path` through the call's own access. -fn seed(call: &TestCall, path: &str, bytes: &[u8]) { - call.context() - .access() - .write(path, bytes) - .expect("a seeded file is written"); -} - -/// A call with `bytes` already at `path`. -fn seeded(path: &str, bytes: &[u8]) -> TestCall { - let call = new_call(); - seed(&call, path, bytes); - call -} - -/// The `read` definition the Pack builds the tool from. -fn definition() -> &'static Definition { - spec::all() - .expect("every embedded definition parses") - .iter() - .find(|definition| definition.segment == "read") - .expect("the read definition exists") -} - -/// Calls Read with `args` as the Pack does: the schema check first, then -/// the tool. -fn read(call: &TestCall, args: &Value) -> Result { - let definition = definition(); - crate::check::check(&definition.wire_name, &definition.parameters, args)?; - super::call(call.context().access(), definition, args) -} - -/// Reads `path` with the window `offset` and `limit` under the byte cap -/// `cap`. -fn read_capped( - call: &TestCall, - path: &str, - offset: Option, - limit: Option, - cap: usize, -) -> Result { - let request = Request { - path, - offset, - limit, - }; - super::read_file(call.context().access(), "Read", &request, cap) -} - -/// The text of a successful read, which must be untrusted output. -fn text_of(result: Result) -> String { - let output = result.expect("the read succeeds"); - assert_eq!(output.trust(), OutputTrust::Untrusted, "{}", output.text()); - output.text().to_owned() -} - -/// The message of a failed read. -fn failure_of(result: Result) -> String { - match result { - Ok(output) => panic!("the read fails, but it answered {:?}", output.text()), - Err(error) => error.to_string(), - } -} - -/// A file of `count` lines, `line 1` to `line {count}`, with or without a -/// final newline. -fn numbered_file(count: usize, final_newline: bool) -> String { - let mut text = (1..=count) - .map(|n| format!("line {n}")) - .collect::>() - .join("\n"); - if final_newline && count > 0 { - text.push('\n'); - } - text -} diff --git a/crates/plugin-std/src/read/tests/arguments.rs b/crates/plugin-std/src/read/tests/arguments.rs deleted file mode 100644 index b08f58032..000000000 --- a/crates/plugin-std/src/read/tests/arguments.rs +++ /dev/null @@ -1,216 +0,0 @@ -//! The argument rules: what Read refuses before it touches a file, and in -//! what order. - -use runvil_plugin::ErrorCode; -use serde_json::{Value, json}; - -use super::{new_call, numbered_file, read, seeded, text_of}; - -/// The error code and message of a refused call over an empty filesystem. -fn refused(args: &Value) -> (ErrorCode, String) { - let call = new_call(); - match read(&call, args) { - Ok(output) => panic!("{args} is refused, but it answered {:?}", output.text()), - Err(error) => (error.code, error.to_string()), - } -} - -#[test] -fn a_limit_below_one_is_refused_with_the_value_as_given() { - for (limit, passed) in [(json!(0), "0"), (json!(-3), "-3")] { - let (kind, message) = refused(&json!({ "path": "/a.md", "limit": limit })); - assert_eq!(kind, ErrorCode::ToolInvalidArguments); - assert_eq!( - message, - format!("std_Read: `limit` must be at least 1, and the call passed {passed}") - ); - } -} - -#[test] -fn a_string_offset_is_refused_and_not_coerced() { - let (kind, message) = refused(&json!({ "path": "/a.md", "offset": "3" })); - assert_eq!(kind, ErrorCode::ToolInvalidArguments); - assert_eq!( - message, - "std_Read: `offset` must be a JSON integer, and the call passed \"3\"" - ); -} - -#[test] -fn a_string_limit_and_a_fractional_offset_are_refused() { - let (_, message) = refused(&json!({ "path": "/a.md", "limit": "3" })); - assert_eq!( - message, - "std_Read: `limit` must be a JSON integer, and the call passed \"3\"" - ); - let (_, message) = refused(&json!({ "path": "/a.md", "offset": 1.5 })); - assert_eq!( - message, - "std_Read: `offset` must be a JSON integer, and the call passed 1.5" - ); -} - -#[test] -fn an_integer_that_does_not_fit_in_64_bits_is_refused() { - for name in ["offset", "limit"] { - let mut args = json!({ "path": "/a.md" }); - args[name] = json!(u64::MAX); - let (kind, message) = refused(&args); - assert_eq!(kind, ErrorCode::ToolInvalidArguments); - assert_eq!( - message, - format!( - "std_Read: `{name}` must be a JSON integer that fits in 64 bits, \ - and the call passed 18446744073709551615" - ) - ); - } -} - -#[test] -fn a_whole_number_written_as_a_float_reads_as_that_integer() { - let call = seeded("/a.md", numbered_file(10, true).as_bytes()); - for (written, plain) in [ - (json!({ "offset": 3.0 }), json!({ "offset": 3 })), - (json!({ "offset": -2.0 }), json!({ "offset": -2 })), - (json!({ "offset": 0.0 }), json!({ "offset": 0 })), - (json!({ "limit": 2.0 }), json!({ "limit": 2 })), - ( - json!({ "offset": 4.0, "limit": 3.0 }), - json!({ "offset": 4, "limit": 3 }), - ), - ( - json!({ "offset": -9_223_372_036_854_775_808.0_f64 }), - json!({ "offset": i64::MIN }), - ), - ] { - let mut written_args = written.clone(); - written_args["path"] = json!("/a.md"); - let mut plain_args = plain; - plain_args["path"] = json!("/a.md"); - assert_eq!( - text_of(read(&call, &written_args)), - text_of(read(&call, &plain_args)), - "{written}" - ); - } -} - -#[test] -fn a_whole_number_float_past_the_64_bit_range_is_refused() { - for (name, passed) in [ - ("offset", json!(1e30)), - ("limit", json!(1e30)), - ("offset", json!(9_223_372_036_854_775_808.0_f64)), - ("offset", json!(-1e19)), - ] { - let mut args = json!({ "path": "/a.md" }); - args[name] = passed.clone(); - let (kind, message) = refused(&args); - assert_eq!(kind, ErrorCode::ToolInvalidArguments); - assert_eq!( - message, - format!( - "std_Read: `{name}` must be a JSON integer that fits in 64 bits, \ - and the call passed {passed}" - ) - ); - } -} - -#[test] -fn a_float_limit_below_one_is_refused_with_the_value_as_given() { - let (kind, message) = refused(&json!({ "path": "/a.md", "limit": 0.0 })); - assert_eq!(kind, ErrorCode::ToolInvalidArguments); - assert_eq!( - message, - "std_Read: `limit` must be at least 1, and the call passed 0.0" - ); -} - -#[test] -fn an_undeclared_key_is_refused_naming_it_and_the_parameters() { - let (kind, message) = refused(&json!({ "path": "/a.md", "lines": 2 })); - assert_eq!(kind, ErrorCode::ToolInvalidArguments); - assert_eq!( - message, - "std_Read: `lines` is not a parameter of this tool. \ - The parameters are `limit`, `offset` and `path`." - ); -} - -#[test] -fn a_blank_path_is_refused_before_any_io() { - for (blank, shown) in [("", ""), (" ", " ")] { - let (kind, message) = refused(&json!({ "path": blank })); - assert_eq!(kind, ErrorCode::ToolInvalidArguments); - assert_eq!( - message, - format!( - "std_Read: `path` is empty or only whitespace, and the call passed \"{shown}\"" - ) - ); - } -} - -#[test] -fn a_missing_path_is_refused_by_the_shared_check() { - let (kind, message) = refused(&json!({})); - assert_eq!(kind, ErrorCode::ToolInvalidArguments); - assert_eq!( - message, - "std_Read: `path` is required, and the call left it out" - ); -} - -#[test] -fn the_unknown_key_is_refused_before_the_blank_path() { - let (_, message) = refused(&json!({ "path": " ", "bogus": 1 })); - assert!(message.contains("`bogus` is not a parameter"), "{message}"); -} - -#[test] -fn the_blank_path_is_refused_before_the_limit() { - let (_, message) = refused(&json!({ "path": "", "limit": 0 })); - assert!( - message.contains("`path` is empty or only whitespace"), - "{message}" - ); -} - -#[test] -fn the_limit_is_refused_before_the_file_is_looked_up() { - let (kind, message) = refused(&json!({ "path": "/missing.md", "limit": 0 })); - assert_eq!(kind, ErrorCode::ToolInvalidArguments); - assert!(message.contains("`limit` must be at least 1"), "{message}"); -} - -#[test] -fn a_missing_file_is_a_plain_message_and_not_an_invalid_arguments_error() { - let (kind, message) = refused(&json!({ "path": "/missing.md" })); - assert_ne!(kind, ErrorCode::ToolInvalidArguments); - assert_eq!(message, "File not found: /missing.md"); -} - -#[test] -fn a_null_offset_and_a_null_limit_read_as_absent() { - let call = seeded("/a.md", b"one\ntwo\n"); - let plain = text_of(read(&call, &json!({ "path": "/a.md" }))); - let nulls = text_of(read( - &call, - &json!({ "path": "/a.md", "offset": null, "limit": null }), - )); - assert_eq!(plain, nulls); - assert_eq!(plain, " 1|one\n 2|two\n"); -} - -#[test] -fn a_null_argument_list_is_refused_for_its_missing_path() { - let (kind, message) = refused(&Value::Null); - assert_eq!(kind, ErrorCode::ToolInvalidArguments); - assert_eq!( - message, - "std_Read: `path` is required, and the call left it out" - ); -} diff --git a/crates/plugin-std/src/read/tests/failures.rs b/crates/plugin-std/src/read/tests/failures.rs deleted file mode 100644 index c3e9d56c7..000000000 --- a/crates/plugin-std/src/read/tests/failures.rs +++ /dev/null @@ -1,218 +0,0 @@ -//! What Read says when it cannot read, and how it treats the files it can: -//! encodings, binary data, empty files, and the size ceiling. - -use runvil_plugin::{FileType, OutputTrust}; -use serde_json::json; - -use super::{failure_of, new_call, read, read_capped, seed, seeded, text_of}; -use crate::read::{BYTE_CAP, check_type}; - -#[test] -fn a_missing_path_fails_with_the_not_found_text() { - let call = new_call(); - assert_eq!( - failure_of(read(&call, &json!({ "path": "/nope.md" }))), - "File not found: /nope.md" - ); -} - -#[test] -fn a_relative_path_is_echoed_as_written() { - let call = new_call(); - assert_eq!( - failure_of(read(&call, &json!({ "path": "notes/a.md" }))), - "File not found: notes/a.md" - ); -} - -#[test] -fn a_directory_fails_with_the_directory_text() { - let call = seeded("/dir/a.md", b"x\n"); - assert_eq!( - failure_of(read(&call, &json!({ "path": "/dir" }))), - "Path is a directory, not a file: /dir" - ); -} - -#[test] -fn the_file_type_decision_covers_every_kind_of_node() { - assert_eq!(check_type("/p", FileType::File), Ok(())); - assert_eq!(check_type("/p", FileType::Symlink), Ok(())); - assert_eq!( - check_type("/p", FileType::Directory), - Err("Path is a directory, not a file: /p".to_owned()) - ); - for special in [ - FileType::Fifo, - FileType::Socket, - FileType::CharDevice, - FileType::BlockDevice, - ] { - assert_eq!( - check_type("/p", special), - Err("Path is neither a file nor a directory: /p".to_owned()), - "{special:?}" - ); - } -} - -#[test] -fn a_binary_file_is_refused_by_its_content_not_its_name() { - let call = seeded("/data.md", &[0x00, 0x01, 0x02, 0x03, 0xFF]); - assert_eq!( - failure_of(read(&call, &json!({ "path": "/data.md" }))), - "File content is binary data, not text: /data.md" - ); - let text = seeded("/data.bin", b"plain text\n"); - assert_eq!( - text_of(read(&text, &json!({ "path": "/data.bin" }))), - " 1|plain text\n" - ); -} - -#[test] -fn a_file_one_byte_over_the_ceiling_is_refused_and_never_truncated() { - let call = seeded("/big.txt", &vec![b'a'; 16_777_217]); - assert_eq!( - failure_of(read(&call, &json!({ "path": "/big.txt" }))), - "File is too large to read: /big.txt is over the limit of 16777216 bytes." - ); -} - -#[test] -fn a_file_exactly_at_the_ceiling_is_read() { - let call = seeded("/edge.txt", &vec![b'a'; 16_777_216]); - let text = text_of(read(&call, &json!({ "path": "/edge.txt" }))); - assert!(text.starts_with(" 1|aaaa"), "the line is numbered"); - assert!( - text.contains("[Line 1 is 16777216 bytes and is cut after 99992 bytes."), - "{}", - &text[text.len() - 150..] - ); -} - -#[test] -fn the_ceiling_is_checked_before_the_binary_sniff() { - let call = seeded("/big.bin", &vec![0u8; 16_777_217]); - assert!( - failure_of(read(&call, &json!({ "path": "/big.bin" }))).starts_with("File is too large") - ); -} - -#[test] -fn an_empty_file_answers_file_is_empty_whatever_the_window() { - let call = seeded("/empty.md", b""); - for args in [ - json!({ "path": "/empty.md" }), - json!({ "path": "/empty.md", "offset": 500 }), - json!({ "path": "/empty.md", "offset": -3, "limit": 2 }), - json!({ "path": "/empty.md", "limit": 1 }), - ] { - assert_eq!(text_of(read(&call, &args)), "File is empty.", "{args}"); - } -} - -#[test] -fn a_file_of_only_a_byte_order_mark_is_empty() { - let call = seeded("/mark.md", b"\xEF\xBB\xBF"); - assert_eq!( - text_of(read(&call, &json!({ "path": "/mark.md", "offset": 9 }))), - "File is empty." - ); - let utf16 = seeded("/mark16.md", &[0xFF, 0xFE]); - assert_eq!( - text_of(read(&utf16, &json!({ "path": "/mark16.md" }))), - "File is empty." - ); -} - -#[test] -fn a_utf8_byte_order_mark_is_not_part_of_the_first_line() { - let call = seeded("/mark.md", b"\xEF\xBB\xBFhello\n"); - assert_eq!( - text_of(read(&call, &json!({ "path": "/mark.md" }))), - " 1|hello\n" - ); -} - -#[test] -fn a_utf16_file_is_decoded_and_the_note_names_its_byte_order() { - let mut le = vec![0xFF, 0xFE]; - let mut be = vec![0xFE, 0xFF]; - for unit in "a\nb\n".encode_utf16() { - le.extend_from_slice(&unit.to_le_bytes()); - be.extend_from_slice(&unit.to_be_bytes()); - } - for (name, bytes, order) in [("/le.txt", le, "UTF-16LE"), ("/be.txt", be, "UTF-16BE")] { - let call = seeded(name, &bytes); - assert_eq!( - text_of(read(&call, &json!({ "path": name }))), - format!(" 1|a\n 2|b\n[Note: the file is {order} and was decoded to text.]"), - "{name}" - ); - } -} - -#[test] -fn a_utf16_file_without_a_mark_is_binary() { - let call = seeded("/bare16.txt", &[b'h', 0, b'i', 0, b'\n', 0]); - assert_eq!( - failure_of(read(&call, &json!({ "path": "/bare16.txt" }))), - "File content is binary data, not text: /bare16.txt" - ); -} - -#[test] -fn an_invalid_byte_is_shown_as_the_replacement_character_with_a_note() { - let call = seeded("/latin.txt", &[0x61, 0xFF, 0x62, 0x0A]); - assert_eq!( - text_of(read(&call, &json!({ "path": "/latin.txt" }))), - " 1|a\u{FFFD}b\n\ - [Note: 1 invalid UTF-8 sequence in this file were shown as U+FFFD.]" - ); -} - -#[test] -fn a_file_that_ends_mid_sequence_counts_one_replacement() { - let call = seeded("/cut.txt", &[b'a', b'\n', b'b', 0xE2, 0x82]); - assert_eq!( - text_of(read(&call, &json!({ "path": "/cut.txt" }))), - " 1|a\n 2|b\u{FFFD}\n\ - [Note: 1 invalid UTF-8 sequence in this file were shown as U+FFFD.]" - ); -} - -#[test] -fn the_replacement_note_comes_before_the_trailer() { - let call = seeded("/two.txt", &[0x61, 0xFF, 0x0A, 0x62, 0x0A]); - assert_eq!( - text_of(read(&call, &json!({ "path": "/two.txt", "limit": 1 }))), - " 1|a\u{FFFD}\n\ - [Note: 1 invalid UTF-8 sequence in this file were shown as U+FFFD.]\n\ - [Showing lines 1-1 of 2. Use offset=2 to continue.]" - ); -} - -#[test] -fn every_successful_read_is_untrusted_output() { - let call = seeded("/a.md", b"one\ntwo\n"); - seed(&call, "/empty.md", b""); - seed(&call, "/long.md", "x".repeat(150_000).as_bytes()); - for args in [ - json!({ "path": "/a.md" }), - json!({ "path": "/a.md", "offset": 2 }), - json!({ "path": "/a.md", "limit": 1 }), - json!({ "path": "/empty.md" }), - json!({ "path": "/long.md" }), - ] { - let output = read(&call, &args).expect("the read succeeds"); - assert_eq!(output.trust(), OutputTrust::Untrusted, "{args}"); - } - let capped = read_capped(&call, "/a.md", None, None, 10).expect("a cut read succeeds"); - assert_eq!(capped.trust(), OutputTrust::Untrusted); -} - -#[test] -fn the_byte_cap_is_one_hundred_thousand_bytes() { - assert_eq!(BYTE_CAP, 100_000); -} diff --git a/crates/plugin-std/src/read/tests/paging.rs b/crates/plugin-std/src/read/tests/paging.rs deleted file mode 100644 index ce6d9c0dd..000000000 --- a/crates/plugin-std/src/read/tests/paging.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Paging: following each `Use offset=N` trailer until the read ends must -//! rebuild the file exactly, whatever the cap and the limit. - -use super::{numbered_file, read_capped, seeded, text_of}; -use crate::read::BYTE_CAP; - -/// Reads `/file.txt` page by page under `cap` and `limit`, following each -/// trailer's offset, and returns the text of every line it printed. -fn rebuild(file: &str, total: usize, cap: usize, limit: Option) -> Vec { - let call = seeded("/file.txt", file.as_bytes()); - let mut rebuilt: Vec = Vec::new(); - let mut offset: Option = None; - let mut pages = 0usize; - loop { - pages += 1; - assert!(pages <= total + 1, "paging does not advance"); - let text = text_of(read_capped(&call, "/file.txt", offset, limit, cap)); - let mut shown: Vec<&str> = text.split('\n').collect(); - if shown.last() == Some(&"") { - shown.pop(); - } - let trailer = shown - .last() - .copied() - .filter(|line| line.starts_with("[Showing lines ")); - if trailer.is_some() { - shown.pop(); - } - assert!(!shown.is_empty(), "a page is never empty: {text}"); - for line in shown { - let number = rebuilt.len() + 1; - let (prefix, content) = line.split_at(7); - assert_eq!(prefix, format!("{number:>6}|"), "page {pages}"); - rebuilt.push(content.to_owned()); - } - let Some(trailer) = trailer else { - break; - }; - let next: i64 = trailer - .split("offset=") - .nth(1) - .and_then(|rest| rest.split(' ').next()) - .and_then(|digits| digits.parse().ok()) - .unwrap_or_else(|| panic!("the trailer names an offset: {trailer}")); - assert_eq!( - usize::try_from(next).expect("the offset is positive"), - rebuilt.len() + 1, - "the trailer continues where the page stopped" - ); - offset = Some(next); - } - assert_eq!(rebuilt.len(), total, "the pages cover the file"); - rebuilt -} - -#[test] -fn following_the_trailers_rebuilds_the_file_under_every_cap_and_limit() { - for total in [1, 2, 5, 37, 2_000, 2_001] { - for final_newline in [true, false] { - let file = numbered_file(total, final_newline); - let expected: Vec = (1..=total).map(|n| format!("line {n}")).collect(); - for cap in [BYTE_CAP, 300, 90] { - for limit in [None, Some(50), Some(7)] { - let rebuilt = rebuild(&file, total, cap, limit); - assert_eq!( - rebuilt, expected, - "{total} lines, final newline {final_newline}, cap {cap}, limit {limit:?}" - ); - } - } - } - } -} - -#[test] -fn a_small_cap_makes_more_pages_than_a_large_one() { - let file = numbered_file(37, true); - let pages = |cap: usize| { - let call = seeded("/file.txt", file.as_bytes()); - let mut count = 0; - let mut offset: Option = None; - loop { - count += 1; - let text = text_of(read_capped(&call, "/file.txt", offset, None, cap)); - let Some(rest) = text.rsplit_once("offset=") else { - return count; - }; - let digits = rest.1.trim_end_matches(" to continue.]"); - offset = Some(digits.parse().expect("the offset is a number")); - } - }; - assert_eq!(pages(BYTE_CAP), 1); - assert!(pages(90) > pages(300)); - assert!(pages(300) > 1); -} diff --git a/crates/plugin-std/src/read/tests/render.rs b/crates/plugin-std/src/read/tests/render.rs deleted file mode 100644 index f2d820645..000000000 --- a/crates/plugin-std/src/read/tests/render.rs +++ /dev/null @@ -1,304 +0,0 @@ -//! The render: the numbered lines, the byte cap, the trailer, the cut of a -//! first line that alone passes the cap, and the notes. - -use super::{read_capped, seeded, text_of}; -use crate::read::BYTE_CAP; -use crate::read::render::{notes, render}; -use crate::read::window::Window; - -/// The three lines most render tests print. Each costs 11 bytes: a -/// seven-byte prefix, three bytes of text, and a newline. -const LINES: [&str; 3] = ["aaa", "bbb", "ccc"]; - -/// The window over every line of [`LINES`]. -const ALL: Window = Window { first: 1, last: 3 }; - -#[test] -fn every_line_prints_as_a_six_wide_number_a_pipe_and_the_text() { - let text = render(&LINES, &ALL, &[], BYTE_CAP); - assert_eq!(text, " 1|aaa\n 2|bbb\n 3|ccc\n"); -} - -#[test] -fn a_window_in_the_middle_keeps_the_real_line_numbers() { - let text = render(&LINES, &Window { first: 2, last: 3 }, &[], BYTE_CAP); - assert_eq!(text, " 2|bbb\n 3|ccc\n"); -} - -#[test] -fn a_window_that_stops_early_ends_with_the_limit_trailer() { - let text = render(&LINES, &Window { first: 2, last: 2 }, &[], BYTE_CAP); - assert_eq!( - text, - " 2|bbb\n[Showing lines 2-2 of 3. Use offset=3 to continue.]" - ); -} - -#[test] -fn lines_that_exactly_fill_the_cap_all_print_and_no_trailer_follows() { - let text = render(&LINES, &ALL, &[], 33); - assert_eq!(text, " 1|aaa\n 2|bbb\n 3|ccc\n"); -} - -#[test] -fn the_line_that_would_pass_the_cap_is_left_out_and_the_trailer_names_the_cap() { - let text = render(&LINES, &ALL, &[], 32); - assert_eq!( - text, - " 1|aaa\n 2|bbb\n\ - [Showing lines 1-2 of 3 (32-byte limit). Use offset=3 to continue.]" - ); -} - -#[test] -fn the_cap_counts_each_prefix_the_text_and_the_newline() { - for (cap, printed) in [(11, 1), (21, 1), (22, 2), (32, 2), (33, 3)] { - let text = render(&LINES, &ALL, &[], cap); - let shown = text.lines().filter(|line| line.contains('|')).count(); - assert_eq!(shown, printed, "cap {cap}: {text}"); - } -} - -#[test] -fn the_cap_trailer_wins_over_the_limit_when_the_cap_stops_first() { - let text = render(&LINES, &Window { first: 1, last: 2 }, &[], 11); - assert_eq!( - text, - " 1|aaa\n[Showing lines 1-1 of 3 (11-byte limit). Use offset=2 to continue.]" - ); -} - -#[test] -fn a_first_line_that_alone_passes_the_cap_is_cut_and_the_trailer_says_so() { - let text = render(&LINES, &ALL, &[], 10); - assert_eq!( - text, - " 1|aa\n[Line 1 is 3 bytes and is cut after 2 bytes. \ - No parameter reads the rest of a line. Use offset=2 to continue.]" - ); -} - -#[test] -fn a_cut_first_line_on_the_last_line_of_the_file_offers_no_next_offset() { - let text = render(&LINES, &Window { first: 3, last: 3 }, &[], 10); - assert_eq!( - text, - " 3|cc\n[Line 3 is 3 bytes and is cut after 2 bytes. \ - No parameter reads the rest of a line.]" - ); -} - -#[test] -fn a_cap_below_the_prefix_keeps_no_text_and_does_not_panic() { - let text = render(&LINES, &ALL, &[], 7); - assert_eq!( - text, - " 1|\n[Line 1 is 3 bytes and is cut after 0 bytes. \ - No parameter reads the rest of a line. Use offset=2 to continue.]" - ); -} - -#[test] -fn notes_come_after_the_lines_and_before_the_trailer_each_on_its_own_line() { - let note_lines = ["[Note: one]".to_owned(), "[Note: two]".to_owned()]; - let with_trailer = render(&LINES, &Window { first: 1, last: 1 }, ¬e_lines, BYTE_CAP); - assert_eq!( - with_trailer, - " 1|aaa\n[Note: one]\n[Note: two]\n\ - [Showing lines 1-1 of 3. Use offset=2 to continue.]" - ); - let without = render(&LINES, &ALL, ¬e_lines, BYTE_CAP); - assert_eq!( - without, - " 1|aaa\n 2|bbb\n 3|ccc\n[Note: one]\n[Note: two]" - ); -} - -#[test] -fn a_utf16_file_adds_a_note_naming_its_byte_order() { - assert_eq!( - notes(Some("UTF-16LE"), 0), - ["[Note: the file is UTF-16LE and was decoded to text.]"] - ); - assert_eq!( - notes(Some("UTF-16BE"), 0), - ["[Note: the file is UTF-16BE and was decoded to text.]"] - ); -} - -#[test] -fn replaced_bytes_add_a_note_that_counts_the_sequences() { - assert_eq!( - notes(None, 1), - ["[Note: 1 invalid UTF-8 sequence in this file were shown as U+FFFD.]"] - ); - assert_eq!( - notes(None, 3), - ["[Note: 3 invalid UTF-8 sequences in this file were shown as U+FFFD.]"] - ); -} - -#[test] -fn the_utf16_note_comes_before_the_replacement_note_and_clean_text_has_none() { - assert_eq!( - notes(Some("UTF-16LE"), 2), - [ - "[Note: the file is UTF-16LE and was decoded to text.]", - "[Note: 2 invalid UTF-8 sequences in this file were shown as U+FFFD.]" - ] - ); - assert!(notes(None, 0).is_empty()); -} - -#[test] -fn five_thousand_short_lines_stop_at_2083_lines_under_the_default_cap() { - let file = format!("{}\n", "x".repeat(40)).repeat(5_000); - let call = seeded("/many.txt", file.as_bytes()); - let text = text_of(read_capped(&call, "/many.txt", None, None, BYTE_CAP)); - let trailer = - "[Showing lines 1-2083 of 5000 (100000-byte limit). Use offset=2084 to continue.]"; - assert!( - text.ends_with(&format!("\n{trailer}")), - "the trailer ends the read" - ); - let numbered = text - .lines() - .filter(|line| line.as_bytes().get(6) == Some(&b'|')); - assert_eq!(numbered.count(), 2_083); - assert_eq!( - text.len() - trailer.len(), - 2_083 * 48, - "each numbered line is 7 + 40 + 1 bytes and the trailer is not counted" - ); - assert!(text.len() <= 100_120, "{}", text.len()); -} - -/// What Read answers for a file whose first line is `line`, followed by -/// a second line when `later` is set, split into the line it printed -/// after its prefix and the trailer. -fn first_line_cut(line: &str, later: bool) -> (String, String) { - let mut file = line.to_owned(); - file.push('\n'); - if later { - file.push_str("next\n"); - } - let call = seeded("/long.txt", file.as_bytes()); - let text = text_of(read_capped(&call, "/long.txt", None, None, BYTE_CAP)); - let (shown, trailer) = text.split_once('\n').expect("a cut read has a trailer"); - let kept = shown - .strip_prefix(" 1|") - .expect("the first line is numbered"); - (kept.to_owned(), trailer.to_owned()) -} - -#[test] -fn a_150000_byte_ascii_line_keeps_99992_bytes() { - let line = "x".repeat(150_000); - let (kept, trailer) = first_line_cut(&line, false); - assert_eq!(kept.len(), 99_992); - assert!(line.starts_with(&kept)); - assert_eq!( - trailer, - "[Line 1 is 150000 bytes and is cut after 99992 bytes. \ - No parameter reads the rest of a line.]" - ); - let (_, trailer) = first_line_cut(&line, true); - assert_eq!( - trailer, - "[Line 1 is 150000 bytes and is cut after 99992 bytes. \ - No parameter reads the rest of a line. Use offset=2 to continue.]" - ); -} - -#[test] -fn the_cut_numbered_line_fills_the_cap_exactly_for_ascii() { - let call = seeded("/long.txt", "x".repeat(150_000).as_bytes()); - let text = text_of(read_capped(&call, "/long.txt", None, None, BYTE_CAP)); - let first = text.split_once('\n').expect("one numbered line").0; - assert_eq!(first.len() + 1, 100_000); -} - -#[test] -fn fifty_thousand_cjk_characters_keep_99990_bytes() { - let line = "\u{4E2D}".repeat(50_000); - let (kept, trailer) = first_line_cut(&line, false); - assert_eq!(kept.len(), 99_990); - assert_eq!(kept.chars().count(), 33_330); - assert!( - trailer.contains("is 150000 bytes and is cut after 99990 bytes."), - "{trailer}" - ); -} - -#[test] -fn a_line_of_two_byte_characters_keeps_99992_bytes() { - let line = "\u{E9}".repeat(75_000); - let (kept, trailer) = first_line_cut(&line, false); - assert_eq!(kept.len(), 99_992); - assert!(line.starts_with(&kept)); - assert!(trailer.contains("cut after 99992 bytes."), "{trailer}"); -} - -#[test] -fn a_line_of_four_byte_emoji_keeps_24998_of_them() { - let line = "\u{1F600}".repeat(37_500); - let (kept, trailer) = first_line_cut(&line, false); - assert_eq!(kept.len(), 99_992); - assert_eq!(kept.chars().count(), 24_998); - assert!(trailer.contains("cut after 99992 bytes."), "{trailer}"); -} - -#[test] -fn a_cut_at_a_later_line_names_that_line() { - let file = format!("short\n{}\ntail\n", "x".repeat(150_000)); - let call = seeded("/long.txt", file.as_bytes()); - let text = text_of(read_capped(&call, "/long.txt", Some(2), None, BYTE_CAP)); - let trailer = text.split_once('\n').expect("a cut read has a trailer").1; - assert_eq!( - trailer, - "[Line 2 is 150000 bytes and is cut after 99992 bytes. \ - No parameter reads the rest of a line. Use offset=3 to continue.]" - ); -} - -#[test] -fn a_line_that_only_just_fits_is_not_cut() { - let line = "x".repeat(BYTE_CAP - 8); - let call = seeded("/fit.txt", format!("{line}\n").as_bytes()); - let text = text_of(read_capped(&call, "/fit.txt", None, None, BYTE_CAP)); - assert_eq!(text, format!(" 1|{line}\n")); -} - -#[test] -fn a_cap_sweep_never_splits_a_character_and_the_notice_counts_the_kept_bytes() { - let line = "a\u{E9}\u{20AC}\u{1F600}".repeat(30); - let call = seeded("/mixed.txt", format!("{line}\n").as_bytes()); - for cap in 12..=60 { - let text = text_of(read_capped(&call, "/mixed.txt", None, None, cap)); - let (shown, trailer) = text.split_once('\n').expect("a cut read has a trailer"); - let kept = shown - .strip_prefix(" 1|") - .expect("the first line is numbered"); - let allowance = cap - 8; - assert!(line.starts_with(kept), "cap {cap}: a prefix of the line"); - assert!( - kept.len() <= allowance, - "cap {cap}: {} bytes kept", - kept.len() - ); - assert!( - allowance - kept.len() < 4, - "cap {cap}: the cut stops within one character of the allowance" - ); - assert_eq!( - trailer, - format!( - "[Line 1 is {} bytes and is cut after {} bytes. \ - No parameter reads the rest of a line.]", - line.len(), - kept.len() - ), - "cap {cap}" - ); - } -} diff --git a/crates/plugin-std/src/read/tests/window.rs b/crates/plugin-std/src/read/tests/window.rs deleted file mode 100644 index 90deff5ad..000000000 --- a/crates/plugin-std/src/read/tests/window.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! The window: which lines an offset and a limit pick, and what Read -//! prints for them. - -use super::{failure_of, numbered_file, read, read_capped, seeded, text_of}; -use crate::read::BYTE_CAP; -use crate::read::window::{Window, window}; -use serde_json::json; - -/// The window of a 10-line file, as first and last line numbers. -fn ten(offset: Option, limit: Option) -> Option<(usize, usize)> { - window(10, offset, limit).map(|picked| (picked.first, picked.last)) -} - -/// An offset, a limit, and the first and last line they pick. -type Case = (Option, Option, Option<(usize, usize)>); - -#[test] -fn the_window_table_on_a_ten_line_file() { - let table: [Case; 13] = [ - (None, None, Some((1, 10))), - (Some(0), None, Some((1, 10))), - (Some(1), None, Some((1, 10))), - (Some(10), None, Some((10, 10))), - (Some(-1), None, Some((10, 10))), - (Some(-2), None, Some((9, 10))), - (Some(-100), None, Some((1, 10))), - (Some(-2), Some(5), Some((9, 10))), - (None, Some(3), Some((1, 3))), - (Some(4), Some(3), Some((4, 6))), - (Some(9), Some(5), Some((9, 10))), - (Some(11), None, None), - (Some(11), Some(3), None), - ]; - for (offset, limit, expected) in table { - assert_eq!( - ten(offset, limit), - expected, - "offset {offset:?}, limit {limit:?}" - ); - } -} - -#[test] -fn the_extremes_of_both_arguments_do_not_overflow() { - assert_eq!(ten(Some(i64::MIN), None), Some((1, 10))); - assert_eq!(ten(Some(i64::MAX), None), None); - assert_eq!(ten(None, Some(usize::MAX)), Some((1, 10))); - assert_eq!(ten(Some(5), Some(usize::MAX)), Some((5, 10))); - assert_eq!(ten(Some(-3), Some(usize::MAX)), Some((8, 10))); -} - -#[test] -fn a_one_line_file_has_one_window() { - assert_eq!(window(1, None, None), Some(Window { first: 1, last: 1 })); - assert_eq!( - window(1, Some(-1), None), - Some(Window { first: 1, last: 1 }) - ); - assert_eq!(window(1, Some(2), None), None); -} - -#[test] -fn a_whole_file_read_numbers_every_line_and_prints_no_trailer() { - let call = seeded("/notes.md", numbered_file(3, true).as_bytes()); - let text = text_of(read(&call, &json!({ "path": "/notes.md" }))); - assert_eq!(text, " 1|line 1\n 2|line 2\n 3|line 3\n"); -} - -#[test] -fn a_file_without_a_final_newline_has_the_same_lines() { - let with = seeded("/a.md", numbered_file(3, true).as_bytes()); - let without = seeded("/a.md", numbered_file(3, false).as_bytes()); - let args = json!({ "path": "/a.md" }); - assert_eq!(text_of(read(&with, &args)), text_of(read(&without, &args))); -} - -#[test] -fn a_blank_last_line_counts_as_a_line() { - let call = seeded("/a.md", b"a\n\n"); - let text = text_of(read(&call, &json!({ "path": "/a.md" }))); - assert_eq!(text, " 1|a\n 2|\n"); -} - -#[test] -fn carriage_returns_are_not_part_of_a_line() { - let call = seeded("/a.md", b"a\r\nb\r\n"); - let text = text_of(read(&call, &json!({ "path": "/a.md" }))); - assert_eq!(text, " 1|a\n 2|b\n"); -} - -#[test] -fn a_lone_carriage_return_inside_a_line_stays_in_the_line() { - let call = seeded("/a.md", b"first\rNEEDLE\rthird\n"); - let text = text_of(read(&call, &json!({ "path": "/a.md" }))); - assert_eq!(text, " 1|first\rNEEDLE\rthird\n"); -} - -#[test] -fn a_limit_that_stops_before_the_end_names_the_next_offset() { - let call = seeded("/ten.md", numbered_file(10, true).as_bytes()); - let text = text_of(read( - &call, - &json!({ "path": "/ten.md", "offset": 4, "limit": 3 }), - )); - assert_eq!( - text, - " 4|line 4\n 5|line 5\n 6|line 6\n\ - [Showing lines 4-6 of 10. Use offset=7 to continue.]" - ); -} - -#[test] -fn a_read_that_reaches_the_last_line_prints_no_trailer() { - let call = seeded("/ten.md", numbered_file(10, true).as_bytes()); - let tail = " 9|line 9\n 10|line 10\n"; - for args in [ - json!({ "path": "/ten.md", "offset": 9, "limit": 5 }), - json!({ "path": "/ten.md", "offset": 9 }), - json!({ "path": "/ten.md", "offset": -2 }), - json!({ "path": "/ten.md", "offset": -2, "limit": 5 }), - json!({ "path": "/ten.md", "offset": 9, "limit": 2 }), - ] { - assert_eq!(text_of(read(&call, &args)), tail, "{args}"); - } -} - -#[test] -fn a_limit_that_ends_exactly_on_the_last_line_prints_no_trailer() { - let call = seeded("/ten.md", numbered_file(10, true).as_bytes()); - let text = text_of(read(&call, &json!({ "path": "/ten.md", "limit": 10 }))); - assert!(!text.contains("[Showing"), "{text}"); -} - -#[test] -fn the_negative_offsets_read_the_tail_of_the_file() { - let call = seeded("/ten.md", numbered_file(10, true).as_bytes()); - let last = text_of(read(&call, &json!({ "path": "/ten.md", "offset": -1 }))); - assert_eq!(last, " 10|line 10\n"); - let all = text_of(read(&call, &json!({ "path": "/ten.md", "offset": -100 }))); - assert_eq!(all, text_of(read(&call, &json!({ "path": "/ten.md" })))); -} - -#[test] -fn an_offset_of_zero_reads_as_the_first_line() { - let call = seeded("/ten.md", numbered_file(10, true).as_bytes()); - let zero = text_of(read( - &call, - &json!({ "path": "/ten.md", "offset": 0, "limit": 2 }), - )); - assert_eq!( - zero, - " 1|line 1\n 2|line 2\n[Showing lines 1-2 of 10. Use offset=3 to continue.]" - ); -} - -#[test] -fn an_offset_past_the_end_fails_with_the_line_count() { - let ten = seeded("/notes.md", numbered_file(10, true).as_bytes()); - assert_eq!( - failure_of(read(&ten, &json!({ "path": "/notes.md", "offset": 11 }))), - "Offset 11 is beyond the end of /notes.md, which has 10 lines." - ); - let one = seeded("/one.md", b"only\n"); - assert_eq!( - failure_of(read(&one, &json!({ "path": "/one.md", "offset": 2 }))), - "Offset 2 is beyond the end of /one.md, which has 1 line." - ); -} - -#[test] -fn the_line_number_widens_past_six_digits_and_the_prefix_stays_in_the_cap() { - let call = seeded("/many.txt", "\n".repeat(1_000_000).as_bytes()); - let last = text_of(read_capped(&call, "/many.txt", Some(-1), None, BYTE_CAP)); - assert_eq!(last, "1000000|\n"); - let two = text_of(read_capped(&call, "/many.txt", Some(-2), None, BYTE_CAP)); - assert_eq!(two, "999999|\n1000000|\n"); -} diff --git a/crates/plugin-std/src/read/window.rs b/crates/plugin-std/src/read/window.rs deleted file mode 100644 index f20c270be..000000000 --- a/crates/plugin-std/src/read/window.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! The window: which lines of a file an offset and a limit pick. - -/// A run of lines, as the numbers of its first and last line, counting -/// from 1. Both ends are inside the file. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct Window { - /// The first line, at least 1. - pub(super) first: usize, - /// The last line, at least `first` and at most the line count. - pub(super) last: usize, -} - -/// The window an `offset` and a `limit` pick over a file of `total` lines. -/// -/// `total` is at least 1, and `limit`, when there is one, is at least 1. -/// An absent offset starts at line 1, and so does an offset of `0`. A -/// positive offset starts there, and picks nothing (`None`) when it is -/// past the last line. A negative offset counts back from the end, so -/// `-1` is the last line, and it never picks nothing: it stops at line 1 -/// when it reaches past the start. The window ends at the last line, or -/// `limit` lines after its start when that is sooner. -pub(super) fn window(total: usize, offset: Option, limit: Option) -> Option { - let first = match offset { - None | Some(0) => 1, - Some(offset) if offset > 0 => { - let first = usize::try_from(offset).unwrap_or(usize::MAX); - if first > total { - return None; - } - first - } - Some(offset) => { - let back = usize::try_from(offset.unsigned_abs()).unwrap_or(usize::MAX); - total.saturating_sub(back) + 1 - } - }; - let last = limit.map_or(total, |limit| { - first.saturating_add(limit.saturating_sub(1)).min(total) - }); - Some(Window { first, last }) -} diff --git a/crates/plugin-std/src/refusal.rs b/crates/plugin-std/src/refusal.rs deleted file mode 100644 index f6c0ab5a6..000000000 --- a/crates/plugin-std/src/refusal.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! The sentence a model reads when a file operation fails. -//! -//! Every file tool turns a [`VfsError`] into one of these, so the same -//! failure reads the same in each tool. The path in a sentence is always -//! the path as the model wrote it, never the canonical path the error -//! holds, so a relative `notes/a.md` is not echoed back as `/notes/a.md`. - -use runvil_plugin::VfsError; - -/// The model-readable sentence for `error`, from the tool called `tool` -/// (the name the model uses) on `path` (as the model wrote it). -/// -/// A sentence names no tool other than `tool`. -pub(crate) fn refusal(tool: &str, path: &str, error: &VfsError) -> String { - match error { - VfsError::NotFound { .. } => format!("File not found: {path}"), - VfsError::IsADirectory { .. } => format!("Path is a directory, not a file: {path}"), - VfsError::NotADirectory { .. } => not_a_directory(path), - VfsError::Conflict { .. } => format!( - "{path} was changed, or is being changed, by another call in this round, so it \ - cannot be read in this round. Call {tool} again in your next message." - ), - VfsError::Io { kind, message, .. } => format!("I/O failure on {path} ({kind}): {message}"), - VfsError::PermissionDenied { reason, .. } | VfsError::ApprovalRequired { reason, .. } => { - reason.clone() - } - other => without_label(&other.to_string()), - } -} - -/// `text` without its leading label: the text after the first `": "`, or -/// the whole text when it has none. -fn without_label(text: &str) -> String { - text.split_once(": ") - .map_or(text, |(_, rest)| rest) - .to_owned() -} - -/// The sentence for a root the model named that does not exist. -/// -/// A tool that starts from a directory words its own missing root, because -/// the `NotFound` sentence above says a file was not found. -pub(crate) fn missing_root(path: &str) -> String { - format!("Path does not exist: {path}") -} - -/// The sentence for a path that names something other than a directory -/// where a directory was needed. -pub(crate) fn not_a_directory(path: &str) -> String { - format!("Path is not a directory: {path}") -} - -/// The sentence for a path that is neither a file nor a directory, such -/// as a pipe or a device. -pub(crate) fn neither_file_nor_directory(path: &str) -> String { - format!("Path is neither a file nor a directory: {path}") -} - -#[cfg(test)] -mod tests; diff --git a/crates/plugin-std/src/refusal/tests.rs b/crates/plugin-std/src/refusal/tests.rs deleted file mode 100644 index 17d6b5ef4..000000000 --- a/crates/plugin-std/src/refusal/tests.rs +++ /dev/null @@ -1,338 +0,0 @@ -//! Unit tests for the refusal texts: one sentence per `VfsError`, byte -//! for byte, with the path echoed as the model wrote it. - -use std::io::ErrorKind; - -use runvil_plugin::{PathReason, VfsError}; - -use super::{missing_root, neither_file_nor_directory, not_a_directory, refusal}; - -/// The path the model wrote, and the canonical path every error below -/// carries, which differs so a test can tell which one a sentence echoes. -const WRITTEN: &str = "notes/a.md"; -const CANONICAL: &str = "/canonical/a.md"; - -/// What `refusal` says for `error` when `Read` was called on -/// `notes/a.md`. -fn say(error: &VfsError) -> String { - refusal("Read", WRITTEN, error) -} - -#[test] -fn not_found_says_the_file_was_not_found_with_the_path_as_written() { - let error = VfsError::NotFound { - path: CANONICAL.to_owned(), - }; - assert_eq!(say(&error), "File not found: notes/a.md"); -} - -#[test] -fn a_directory_where_a_file_was_named_says_so() { - let error = VfsError::IsADirectory { - path: CANONICAL.to_owned(), - }; - assert_eq!(say(&error), "Path is a directory, not a file: notes/a.md"); -} - -#[test] -fn a_file_where_a_directory_was_named_says_so() { - let error = VfsError::NotADirectory { - path: CANONICAL.to_owned(), - }; - assert_eq!(say(&error), "Path is not a directory: notes/a.md"); -} - -#[test] -fn a_conflict_says_the_path_changed_this_round_and_names_the_tool_to_call_again() { - let error = VfsError::Conflict { - path: CANONICAL.to_owned(), - detail: "write by another call".to_owned(), - }; - assert_eq!( - say(&error), - "notes/a.md was changed, or is being changed, by another call in this round, so it \ - cannot be read in this round. Call Read again in your next message." - ); - assert_eq!( - refusal("Grep", "/src", &error), - "/src was changed, or is being changed, by another call in this round, so it \ - cannot be read in this round. Call Grep again in your next message." - ); -} - -#[test] -fn an_io_failure_names_the_path_the_kind_and_the_message() { - let error = VfsError::Io { - path: CANONICAL.to_owned(), - kind: ErrorKind::TimedOut, - raw_os_error: Some(110), - message: "the disk did not answer".to_owned(), - }; - assert_eq!( - say(&error), - "I/O failure on notes/a.md (timed out): the disk did not answer" - ); -} - -#[test] -fn a_denied_or_unapproved_read_passes_the_policys_reason_through_unchanged() { - let reason = "Mode::Plan allows only markdown paths, and /secret.rs is not one"; - let denied = VfsError::PermissionDenied { - path: CANONICAL.to_owned(), - reason: reason.to_owned(), - }; - assert_eq!(say(&denied), reason); - let approval = "Reading /vault needs approval (rule: vault)"; - let asked = VfsError::ApprovalRequired { - path: CANONICAL.to_owned(), - reason: approval.to_owned(), - }; - assert_eq!(say(&asked), approval); -} - -#[test] -fn every_other_variant_gives_its_display_text_without_the_leading_label() { - let cases: Vec<(VfsError, &str)> = vec![ - ( - VfsError::AlreadyExists { - path: CANONICAL.to_owned(), - }, - "/canonical/a.md", - ), - ( - VfsError::DirectoryNotEmpty { - path: CANONICAL.to_owned(), - }, - "/canonical/a.md", - ), - ( - VfsError::NotUtf8 { - path: CANONICAL.to_owned(), - valid_up_to: 4, - }, - "/canonical/a.md, valid up to byte 4", - ), - ( - VfsError::InvalidPath { - path: "a\\b".to_owned(), - reason: PathReason::Backslash, - }, - "path contains a backslash", - ), - ( - VfsError::InvalidRange { - path: CANONICAL.to_owned(), - reason: "the range is empty", - }, - "the range is empty", - ), - ( - VfsError::Anchor { - path: CANONICAL.to_owned(), - anchor: String::new(), - count: 0, - }, - "/canonical/a.md", - ), - ( - VfsError::Anchor { - path: CANONICAL.to_owned(), - anchor: "old".to_owned(), - count: 0, - }, - "anchor \"old\" was not found in /canonical/a.md, expected exactly one", - ), - ]; - for (error, expected) in cases { - assert_eq!(say(&error), expected, "{error:?}"); - } -} - -#[test] -fn the_state_and_backend_variants_also_drop_the_leading_label() { - let cases: Vec<(VfsError, &str)> = vec![ - ( - VfsError::ScopeEnded { - path: CANONICAL.to_owned(), - }, - "the run or tool call that owned this access has ended, so /canonical/a.md can no \ - longer be touched through it", - ), - ( - VfsError::ReadOnly { - path: CANONICAL.to_owned(), - }, - "/canonical/a.md is served read-only and cannot change", - ), - ( - VfsError::OutsideRoot { - path: CANONICAL.to_owned(), - detail: "/canonical/a.md is a link to /etc/passwd, which is outside the root" - .to_owned(), - }, - "/canonical/a.md is a link to /etc/passwd, which is outside the root", - ), - ( - VfsError::RootProtected { - path: "/".to_owned(), - }, - "the mounted root / cannot be removed, renamed, or replaced", - ), - (VfsError::NoStore, "the handle declares no store"), - ( - VfsError::CrossMount { - path: CANONICAL.to_owned(), - detail: "copy /a to /b".to_owned(), - }, - "copy /a to /b", - ), - ( - VfsError::Unsupported { - path: CANONICAL.to_owned(), - detail: "this backend cannot seek".to_owned(), - }, - "this backend cannot seek", - ), - ( - VfsError::Backend { - message: "the store is offline".to_owned(), - }, - "the store is offline", - ), - ]; - for (error, expected) in cases { - assert_eq!(say(&error), expected, "{error:?}"); - } -} - -#[test] -fn an_outside_root_error_reads_as_its_detail() { - let error = VfsError::OutsideRoot { - path: CANONICAL.to_owned(), - detail: "the link leaves the run's filesystem".to_owned(), - }; - assert_eq!(say(&error), "the link leaves the run's filesystem"); -} - -#[test] -fn a_display_text_with_no_label_is_kept_whole() { - let error = VfsError::Anchor { - path: CANONICAL.to_owned(), - anchor: "x".to_owned(), - count: 3, - }; - assert_eq!( - say(&error), - "anchor \"x\" occurs 3 times in /canonical/a.md, expected exactly one" - ); -} - -#[test] -fn a_relative_path_is_echoed_as_written_and_never_as_the_canonical_one() { - let error = VfsError::NotFound { - path: "/notes/a.md".to_owned(), - }; - let text = say(&error); - assert!(text.ends_with("notes/a.md"), "{text}"); - assert!(!text.contains("/notes/a.md"), "{text}"); -} - -#[test] -fn no_message_names_a_tool_other_than_the_one_called() { - let others = ["Write", "StrReplace", "Delete", "Glob", "Grep", "Shell"]; - let errors = [ - VfsError::NotFound { - path: CANONICAL.to_owned(), - }, - VfsError::IsADirectory { - path: CANONICAL.to_owned(), - }, - VfsError::NotADirectory { - path: CANONICAL.to_owned(), - }, - VfsError::Conflict { - path: CANONICAL.to_owned(), - detail: "x".to_owned(), - }, - VfsError::Io { - path: CANONICAL.to_owned(), - kind: ErrorKind::Other, - raw_os_error: None, - message: "x".to_owned(), - }, - VfsError::Anchor { - path: CANONICAL.to_owned(), - anchor: String::new(), - count: 0, - }, - VfsError::ReadOnly { - path: CANONICAL.to_owned(), - }, - VfsError::NoStore, - VfsError::DirectoryNotEmpty { - path: CANONICAL.to_owned(), - }, - ]; - for error in errors { - let text = say(&error); - for other in others { - assert!(!text.contains(other), "{error:?} says {text}"); - } - } - assert!(!neither_file_nor_directory("p").contains("Shell")); -} - -#[test] -fn a_root_that_does_not_exist_says_the_path_does_not_exist() { - assert_eq!( - missing_root("/home/dev/gone"), - "Path does not exist: /home/dev/gone" - ); - assert_eq!(missing_root("gone"), "Path does not exist: gone"); -} - -#[test] -fn a_missing_root_is_worded_apart_from_a_missing_file() { - // The `NotFound` sentence says file, which is wrong for a directory - // the model named, so the listing and search tools word the root - // themselves. - let error = VfsError::NotFound { - path: CANONICAL.to_owned(), - }; - assert_eq!(say(&error), "File not found: notes/a.md"); - assert_eq!(missing_root(WRITTEN), "Path does not exist: notes/a.md"); -} - -#[test] -fn the_not_a_directory_sentence_is_the_one_the_refusal_gives() { - let error = VfsError::NotADirectory { - path: CANONICAL.to_owned(), - }; - assert_eq!(say(&error), not_a_directory(WRITTEN)); - assert_eq!(not_a_directory("/a/b"), "Path is not a directory: /a/b"); -} - -#[test] -fn the_root_sentences_name_no_tool() { - for text in [missing_root("/p"), not_a_directory("/p")] { - for tool in [ - "Read", - "Write", - "StrReplace", - "Delete", - "Glob", - "Grep", - "Shell", - ] { - assert!(!text.contains(tool), "{text}"); - } - } -} - -#[test] -fn a_special_file_says_it_is_neither_a_file_nor_a_directory() { - assert_eq!( - neither_file_nor_directory("/dev/null"), - "Path is neither a file nor a directory: /dev/null" - ); -} diff --git a/crates/plugin-std/src/spec-tests.rs b/crates/plugin-std/src/spec-tests.rs deleted file mode 100644 index 523898a8b..000000000 --- a/crates/plugin-std/src/spec-tests.rs +++ /dev/null @@ -1,330 +0,0 @@ -//! The definitions: one file per tool, each parsed, each carrying the -//! wire name Cursor uses for the same tool. - -use super::all; - -/// The wire name each id segment must carry, which is Cursor's name for -/// the same tool. -const WIRE_NAMES: [(&str, &str); 7] = [ - ("read", "Read"), - ("write", "Write"), - ("replace", "StrReplace"), - ("rm", "Delete"), - ("glob", "Glob"), - ("grep", "Grep"), - ("dir", "ListDir"), -]; - -#[test] -fn every_definition_parses_and_keeps_the_wire_name_it_was_snapshotted_with() { - let definitions = all().expect("every embedded definition parses"); - let read: Vec<(&str, &str)> = definitions - .iter() - .map(|definition| (definition.segment, definition.wire_name.as_str())) - .collect(); - assert_eq!(read, WIRE_NAMES); -} - -#[test] -fn every_definition_holds_a_description_and_an_object_schema_with_at_least_one_property() { - for definition in all().expect("every embedded definition parses") { - assert!( - !definition.description.trim().is_empty(), - "the {} tool has a description", - definition.segment - ); - assert_eq!( - definition.parameters.get("type").and_then(|t| t.as_str()), - Some("object"), - "the {} tool's schema describes an object: {}", - definition.segment, - definition.parameters - ); - let properties = definition - .parameters - .get("properties") - .and_then(|p| p.as_object()); - assert!( - properties.is_some_and(|properties| !properties.is_empty()), - "the {} tool takes parameters: {}", - definition.segment, - definition.parameters - ); - } -} - -#[test] -fn every_required_parameter_is_one_the_schema_declares_and_describes() { - for definition in all().expect("every embedded definition parses") { - let Some(required) = definition - .parameters - .get("required") - .and_then(|r| r.as_array()) - else { - continue; - }; - let properties = definition - .parameters - .get("properties") - .and_then(|p| p.as_object()) - .expect("a schema with required parameters declares properties"); - for name in required { - let name = name.as_str().expect("a required entry is a string"); - let property = properties.get(name).unwrap_or_else(|| { - panic!( - "the {} tool requires {name}, which its schema does not declare", - definition.segment - ) - }); - assert!( - property - .get("description") - .and_then(|d| d.as_str()) - .is_some_and(|text| !text.trim().is_empty()), - "the {} tool's {name} parameter has a description: {property}", - definition.segment - ); - } - } -} - -#[test] -fn the_read_description_holds_the_limits_the_code_uses_and_no_unfilled_placeholder() { - let definitions = all().expect("every embedded definition parses"); - let read = definitions - .iter() - .find(|definition| definition.segment == "read") - .expect("the read definition exists"); - let text = &read.description; - assert!(text.contains("100000"), "the byte cap is named: {text}"); - assert!( - text.contains("16777216"), - "the file ceiling is named: {text}" - ); - assert!(!text.contains("cat -n"), "the cat -n line is gone: {text}"); - assert!( - !text.contains("2000 characters"), - "the 2000-character line is gone: {text}" - ); - for placeholder in ["{byte_cap}", "{file_ceiling}"] { - assert!( - !text.contains(placeholder), - "{placeholder} is filled in: {text}" - ); - } -} - -#[test] -fn the_dir_description_holds_the_body_cap_and_no_unfilled_placeholder() { - let definitions = all().expect("every embedded definition parses"); - let dir = definitions - .iter() - .find(|definition| definition.segment == "dir") - .expect("the dir definition exists"); - let text = &dir.description; - assert!(text.contains("15000"), "the body cap is named: {text}"); - assert!( - !text.contains("{body_cap}"), - "{{body_cap}} is filled in: {text}" - ); -} - -#[test] -fn the_dir_schema_requires_the_target_and_takes_an_array_of_strings_for_the_ignores() { - let definitions = all().expect("every embedded definition parses"); - let dir = definitions - .iter() - .find(|definition| definition.segment == "dir") - .expect("the dir definition exists"); - let properties = &dir.parameters["properties"]; - assert_eq!(properties["target_directory"]["type"], "string"); - assert_eq!(properties["ignore_globs"]["type"], "array"); - assert_eq!(properties["ignore_globs"]["items"]["type"], "string"); - assert_eq!( - dir.parameters["required"], - serde_json::json!(["target_directory"]) - ); -} - -#[test] -fn the_dir_description_states_the_behavior_the_tool_has() { - let definitions = all().expect("every embedded definition parses"); - let dir = definitions - .iter() - .find(|definition| definition.segment == "dir") - .expect("the dir definition exists"); - let text = &dir.description; - for phrase in [ - "breadth first", - "`.git`", - "dotfile", - "link", - "ignore_globs", - "any depth", - "byte order", - ] { - assert!( - text.contains(phrase), - "the description names {phrase}: {text}" - ); - } -} - -#[test] -fn the_read_schema_takes_integers_for_offset_and_limit() { - let definitions = all().expect("every embedded definition parses"); - let read = definitions - .iter() - .find(|definition| definition.segment == "read") - .expect("the read definition exists"); - for name in ["offset", "limit"] { - assert_eq!( - read.parameters["properties"][name]["type"], "integer", - "{name} is an integer" - ); - } -} - -#[test] -fn no_definition_names_behavior_this_build_does_not_have() { - for definition in all().expect("every embedded definition parses") { - let text = format!("{} {}", definition.description, definition.parameters); - for absent in ["PDF", "Shell tool", "Cursor workspace"] { - assert!( - !text.contains(absent), - "the {} tool mentions {absent}: {text}", - definition.segment - ); - } - } -} - -/// The `grep` definition. -fn grep_definition() -> &'static super::Definition { - all() - .expect("every embedded definition parses") - .iter() - .find(|definition| definition.segment == "grep") - .expect("the grep definition exists") -} - -#[test] -fn the_grep_description_names_the_dialect_the_limits_and_what_is_skipped() { - let text = &grep_definition().description; - for phrase in [ - "Rust regex", - "literal", - "15000", - "16777216", - "100000", - "skipped", - ".gitignore", - "1000 bytes", - "absolute", - "N:", - "N-", - "Read shows", - "no look-around", - "no back-references", - ] { - assert!( - text.contains(phrase), - "the description names {phrase}: {text}" - ); - } - for absent in ["ripgrep", "rg ", "several thousand", "Shell"] { - assert!( - !text.contains(absent), - "the description does not name {absent}: {text}" - ); - } - for placeholder in ["{body_cap}", "{file_ceiling}"] { - assert!( - !text.contains(placeholder), - "{placeholder} is filled in: {text}" - ); - } -} - -#[test] -fn the_grep_schema_has_the_thirteen_parameters_in_order_with_their_types() { - let properties = grep_definition().parameters["properties"] - .as_object() - .expect("the schema has properties"); - // The parsed object is sorted by name, so the order the file declares - // them in is read from the file's own text. - let declared = [ - "pattern", - "path", - "glob", - "output_mode", - "-B", - "-A", - "-C", - "-i", - "type", - "head_limit", - "offset", - "multiline", - "literal", - ]; - let source = include_str!("../schemas/grep.json"); - let positions: Vec = declared - .iter() - .map(|name| { - source - .find(&format!("\n \"{name}\": {{")) - .unwrap_or_else(|| panic!("{name} is declared")) - }) - .collect(); - assert!( - positions.windows(2).all(|pair| pair[0] < pair[1]), - "the parameters are declared in order: {positions:?}" - ); - assert_eq!(properties.len(), declared.len()); - for name in declared { - assert!(properties.contains_key(name), "{name}"); - } - assert!(!properties.contains_key("-n")); - assert!(!properties.contains_key("context")); - for name in ["pattern", "path", "glob", "type", "output_mode"] { - assert_eq!(properties[name]["type"], "string", "{name}"); - } - for name in ["-B", "-A", "-C", "head_limit", "offset"] { - assert_eq!(properties[name]["type"], "number", "{name}"); - } - for name in ["-i", "multiline", "literal"] { - assert_eq!(properties[name]["type"], "boolean", "{name}"); - } - assert_eq!( - properties["output_mode"]["enum"], - serde_json::json!(["content", "files_with_matches", "count"]) - ); - assert_eq!(properties["output_mode"]["default"], "content"); - assert_eq!(properties["literal"]["default"], false); - assert_eq!(properties["head_limit"]["minimum"], 0); - assert!(properties["head_limit"].get("default").is_none()); - assert_eq!( - grep_definition().parameters["required"], - serde_json::json!(["pattern"]) - ); -} - -#[test] -fn the_grep_glob_and_path_texts_say_what_they_take() { - let properties = &grep_definition().parameters["properties"]; - let glob = properties["glob"]["description"] - .as_str() - .expect("glob has a description"); - assert!(glob.contains("\"*.js\""), "{glob}"); - assert!(glob.contains("\"**/*.tsx\""), "{glob}"); - assert!(!glob.contains('{'), "{glob}"); - let path = properties["path"]["description"] - .as_str() - .expect("path has a description"); - assert!(path.contains("absolute"), "{path}"); - let head_limit = properties["head_limit"]["description"] - .as_str() - .expect("head_limit has a description"); - assert!(head_limit.contains("0 for no limit"), "{head_limit}"); -} diff --git a/crates/plugin-std/src/spec.rs b/crates/plugin-std/src/spec.rs deleted file mode 100644 index fda62207e..000000000 --- a/crates/plugin-std/src/spec.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! The seven tool definitions this crate ships, each an embedded JSON file. -//! -//! A file holds the name the model calls the tool by, the description the -//! model reads, and the JSON Schema its arguments must match. The names, -//! descriptions, and schemas are Cursor's own, with the lines that -//! describe behavior this Pack does not have removed, except for -//! `dir.json`, which is this crate's own; `schemas/EDITS.md` records each -//! of those edits and why `dir.json` is new. - -use std::sync::LazyLock; - -use runvil_plugin::{Error, ErrorCode, make_tool_error}; -use serde::Deserialize; -use serde_json::Value; - -/// One tool's definition: its id segment, and the file's own contents. -#[derive(Debug)] -pub(crate) struct Definition { - /// The tool's last id segment, such as `read`, which its file's name - /// gives. - pub(crate) segment: &'static str, - /// The name the model calls the tool by. - pub(crate) wire_name: String, - /// The description the model reads. - pub(crate) description: String, - /// The JSON-Schema `object` the tool's arguments must match. - pub(crate) parameters: Value, -} - -/// One tool's file, as the file itself holds it. -#[derive(Debug, Deserialize)] -struct File { - /// The name the model calls the tool by. - wire_name: String, - /// The description the model reads. - description: String, - /// The JSON-Schema `object` the tool's arguments must match. - parameters: Value, -} - -/// Each tool's id segment beside the source of the file it reads. -const SOURCES: [(&str, &str); 7] = [ - ("read", include_str!("../schemas/read.json")), - ("write", include_str!("../schemas/write.json")), - ("replace", include_str!("../schemas/replace.json")), - ("rm", include_str!("../schemas/rm.json")), - ("glob", include_str!("../schemas/glob.json")), - ("grep", include_str!("../schemas/grep.json")), - ("dir", include_str!("../schemas/dir.json")), -]; - -/// The parsed definitions, one per [`SOURCES`] entry in order. -static DEFINITIONS: LazyLock, Error>> = LazyLock::new(|| { - SOURCES - .iter() - .map(|(segment, source)| { - let file: File = serde_json::from_str(source).map_err(|e| { - make_tool_error( - ErrorCode::ToolBackend, - format!("std could not read the definition of its {segment} tool"), - ) - .with_cause(&e) - })?; - Ok(Definition { - segment, - wire_name: file.wire_name, - description: fill(segment, &file.description), - parameters: file.parameters, - }) - }) - .collect() -}); - -/// `description` with the tool's `{...}` placeholders filled from the -/// constants its code uses, so the text the model reads and the limit the -/// code enforces cannot drift apart. -/// -/// A tool without placeholders keeps its text as it is. -fn fill(segment: &str, description: &str) -> String { - match segment { - "read" => description - .replace("{byte_cap}", &crate::read::BYTE_CAP.to_string()) - .replace("{file_ceiling}", &crate::text::MAX_FILE_BYTES.to_string()), - "dir" => description.replace("{body_cap}", &crate::text::BODY_CAP.to_string()), - "grep" => description - .replace("{body_cap}", &crate::text::BODY_CAP.to_string()) - .replace("{file_ceiling}", &crate::text::MAX_FILE_BYTES.to_string()), - _ => description.to_owned(), - } -} - -/// Every definition, in [`SOURCES`] order. -/// -/// # Errors -/// Returns a [`ToolBackend`](ErrorCode::ToolBackend) error naming the tool -/// whose file does not parse. The files are embedded, so this can only -/// happen after an edit. -pub(crate) fn all() -> Result<&'static [Definition], Error> { - match &*DEFINITIONS { - Ok(definitions) => Ok(definitions), - Err(error) => Err(error.clone()), - } -} - -#[cfg(test)] -#[path = "spec-tests.rs"] -mod tests; diff --git a/crates/plugin-std/src/text.rs b/crates/plugin-std/src/text.rs deleted file mode 100644 index 6fcaf3840..000000000 --- a/crates/plugin-std/src/text.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! How the file tools turn a file's bytes into lines of text. -//! -//! Read and Grep both go through this module, so a line number Grep -//! reports is the number Read prints. Nothing here returns an error to -//! the model for an encoding: bytes that are not valid text are replaced -//! and counted, and a file that is not text at all is told apart by -//! [`sniff_binary`]. - -/// The largest file, in bytes, a file tool reads: 16 MiB. -pub(crate) const MAX_FILE_BYTES: u64 = 16_777_216; - -/// The most bytes of tree or match lines a listing or search returns: -/// 15,000. ListDir and Grep share it. -pub(crate) const BODY_CAP: usize = 15_000; - -/// How many leading bytes [`sniff_binary`] looks at. -const SNIFF_BYTES: usize = 8_192; - -/// A file's bytes decoded to text. -#[derive(Debug, PartialEq, Eq)] -pub(crate) struct Decoded { - /// The text, without a leading byte order mark. - pub(crate) text: String, - /// `UTF-16LE` or `UTF-16BE` when the file started with that mark, - /// and `None` for anything else. - pub(crate) utf16: Option<&'static str>, - /// How many invalid UTF-8 sequences were shown as U+FFFD. It is `0` - /// for a UTF-16 file. - pub(crate) invalid: usize, -} - -/// Decodes `bytes` to text. -/// -/// A leading UTF-8 mark (`EF BB BF`) is dropped. A file that starts with -/// `FF FE` or `FE FF` is UTF-16, little-endian or big-endian: the mark is -/// dropped and the rest is decoded lossily, and a trailing odd byte is -/// ignored. Anything else is decoded as UTF-8, and each invalid chunk -/// becomes one U+FFFD and one count. No other encoding is guessed. -pub(crate) fn decode(bytes: &[u8]) -> Decoded { - if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) { - return utf16(rest, "UTF-16LE", u16::from_le_bytes); - } - if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) { - return utf16(rest, "UTF-16BE", u16::from_be_bytes); - } - let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes); - let mut text = String::with_capacity(bytes.len()); - let mut invalid = 0; - for chunk in bytes.utf8_chunks() { - text.push_str(chunk.valid()); - if !chunk.invalid().is_empty() { - text.push(char::REPLACEMENT_CHARACTER); - invalid += 1; - } - } - Decoded { - text, - utf16: None, - invalid, - } -} - -/// Decodes the UTF-16 `bytes` that follow a mark, reading each unit with -/// `unit`, and names the byte order `name`. -fn utf16(bytes: &[u8], name: &'static str, unit: fn([u8; 2]) -> u16) -> Decoded { - let (pairs, _odd_byte) = bytes.as_chunks::<2>(); - let units: Vec = pairs.iter().map(|pair| unit(*pair)).collect(); - Decoded { - text: String::from_utf16_lossy(&units), - utf16: Some(name), - invalid: 0, - } -} - -/// Whether `bytes` look like binary data rather than text. -/// -/// Only the first 8,192 bytes count. They are binary when any of them is -/// NUL, or when at least 5% of them are control bytes other than tab, -/// line feed, and carriage return. A file that starts with a UTF-16 mark -/// is text whatever it holds, because UTF-16 text has NUL bytes by -/// design; UTF-16 without a mark is binary. -pub(crate) fn sniff_binary(bytes: &[u8]) -> bool { - if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) { - return false; - } - let sample = bytes.get(..SNIFF_BYTES).unwrap_or(bytes); - if sample.contains(&0) { - return true; - } - let controls = sample - .iter() - .filter(|byte| byte.is_ascii_control() && !matches!(**byte, b'\t' | b'\n' | b'\r')) - .count(); - !sample.is_empty() && controls * 20 >= sample.len() -} - -/// Splits `text` into lines on `\n`. -/// -/// The empty piece after a final newline is not a line, and one trailing -/// `\r` is stripped from each line. So `a\nb\n` and `a\nb` are both two -/// lines, an empty text has none, and a lone `\r` inside a line stays in -/// it. -pub(crate) fn split_lines(text: &str) -> Vec<&str> { - text.split_terminator('\n') - .map(|line| line.strip_suffix('\r').unwrap_or(line)) - .collect() -} - -/// The part of `text` from byte `start` to byte `end`, cut on character -/// boundaries. -/// -/// `start` is rounded up and `end` down to the nearest boundary, and both -/// are clamped to the length of `text`. This is the only function that -/// shortens text inside a line, so no caller slices on a raw byte index. -pub(crate) fn clip_to_boundary(text: &str, start: usize, end: usize) -> &str { - let length = text.len(); - let mut start = start.min(length); - let mut end = end.min(length); - while !text.is_char_boundary(start) { - start += 1; - } - while !text.is_char_boundary(end) { - end -= 1; - } - text.get(start..end).unwrap_or("") -} - -#[cfg(test)] -mod tests; diff --git a/crates/plugin-std/src/text/tests.rs b/crates/plugin-std/src/text/tests.rs deleted file mode 100644 index 5bb5b06b0..000000000 --- a/crates/plugin-std/src/text/tests.rs +++ /dev/null @@ -1,285 +0,0 @@ -//! Unit tests for the text module: the decode ladder, the binary sniff, -//! the line splitter, and the boundary clip. - -use super::{BODY_CAP, MAX_FILE_BYTES, clip_to_boundary, decode, sniff_binary, split_lines}; - -/// The UTF-16 little-endian bytes of `text`, behind the `FF FE` mark. -fn utf16_le(text: &str) -> Vec { - let mut bytes = vec![0xFF, 0xFE]; - for unit in text.encode_utf16() { - bytes.extend_from_slice(&unit.to_le_bytes()); - } - bytes -} - -/// The UTF-16 big-endian bytes of `text`, behind the `FE FF` mark. -fn utf16_be(text: &str) -> Vec { - let mut bytes = vec![0xFE, 0xFF]; - for unit in text.encode_utf16() { - bytes.extend_from_slice(&unit.to_be_bytes()); - } - bytes -} - -#[test] -fn the_file_ceiling_is_sixteen_mebibytes() { - assert_eq!(MAX_FILE_BYTES, 16 * 1024 * 1024); - assert_eq!(MAX_FILE_BYTES, 16_777_216); -} - -#[test] -fn the_body_cap_the_listing_and_search_tools_share_is_fifteen_thousand_bytes() { - assert_eq!(BODY_CAP, 15_000); -} - -#[test] -fn plain_utf8_decodes_unchanged_with_no_note() { - let decoded = decode("hello\nwörld €\n".as_bytes()); - assert_eq!(decoded.text, "hello\nwörld €\n"); - assert_eq!(decoded.utf16, None); - assert_eq!(decoded.invalid, 0); -} - -#[test] -fn a_leading_utf8_mark_is_dropped_and_not_reported() { - let decoded = decode(b"\xEF\xBB\xBFhi\n"); - assert_eq!(decoded.text, "hi\n"); - assert_eq!(decoded.utf16, None); - assert_eq!(decoded.invalid, 0); -} - -#[test] -fn a_mark_that_is_not_leading_stays_in_the_text() { - let decoded = decode(b"a\xEF\xBB\xBFb"); - assert_eq!(decoded.text, "a\u{FEFF}b"); -} - -#[test] -fn utf16_little_endian_with_a_mark_is_decoded_and_named() { - let decoded = decode(&utf16_le("hi\nthere\n")); - assert_eq!(decoded.text, "hi\nthere\n"); - assert_eq!(decoded.utf16, Some("UTF-16LE")); - assert_eq!(decoded.invalid, 0); -} - -#[test] -fn utf16_big_endian_with_a_mark_is_decoded_and_named() { - let decoded = decode(&utf16_be("hi\nthere\n")); - assert_eq!(decoded.text, "hi\nthere\n"); - assert_eq!(decoded.utf16, Some("UTF-16BE")); - assert_eq!(decoded.invalid, 0); -} - -#[test] -fn utf16_decodes_a_surrogate_pair_and_replaces_a_lone_surrogate() { - let decoded = decode(&utf16_le("a\u{1F600}b")); - assert_eq!(decoded.text, "a\u{1F600}b"); - let lone = [0xFF, 0xFE, 0x61, 0x00, 0x00, 0xD8, 0x62, 0x00]; - let decoded = decode(&lone); - assert_eq!(decoded.text, "a\u{FFFD}b"); - assert_eq!(decoded.utf16, Some("UTF-16LE")); - assert_eq!(decoded.invalid, 0, "the count is for UTF-8 sequences only"); -} - -#[test] -fn utf16_drops_one_trailing_byte_that_does_not_make_a_unit() { - let decoded = decode(&[0xFF, 0xFE, 0x61, 0x00, 0x62]); - assert_eq!(decoded.text, "a"); - assert_eq!(decoded.utf16, Some("UTF-16LE")); -} - -#[test] -fn a_file_that_is_only_a_mark_decodes_to_nothing() { - assert_eq!(decode(b"\xEF\xBB\xBF").text, ""); - assert_eq!(decode(&[0xFF, 0xFE]).text, ""); - assert_eq!(decode(&[0xFE, 0xFF]).text, ""); - assert_eq!(decode(b"").text, ""); -} - -#[test] -fn an_invalid_byte_becomes_one_replacement_character_and_one_count() { - let decoded = decode(&[0x61, 0xFF, 0x62, 0x0A]); - assert_eq!(decoded.text, "a\u{FFFD}b\n"); - assert_eq!(decoded.utf16, None); - assert_eq!(decoded.invalid, 1); -} - -#[test] -fn a_file_that_ends_mid_sequence_counts_one() { - let decoded = decode(&[0x61, 0xE2, 0x82]); - assert_eq!(decoded.text, "a\u{FFFD}"); - assert_eq!(decoded.invalid, 1); -} - -#[test] -fn separate_invalid_chunks_each_count_once() { - let decoded = decode(&[0xFF, 0x61, 0xC0, 0xC1, 0x62, 0xE2, 0x82]); - assert_eq!(decoded.invalid, 4, "FF, C0, C1, and the cut E2 82"); - assert_eq!(decoded.text.matches('\u{FFFD}').count(), 4); -} - -#[test] -fn bytes_after_a_utf8_mark_are_counted_too() { - let decoded = decode(&[0xEF, 0xBB, 0xBF, 0x61, 0xFF]); - assert_eq!(decoded.text, "a\u{FFFD}"); - assert_eq!(decoded.invalid, 1); -} - -#[test] -fn ordinary_text_is_not_binary() { - assert!(!sniff_binary(b"fn main() {\n\tprintln!(\"hi\");\r\n}\n")); - assert!(!sniff_binary("héllo wörld €\n".as_bytes())); - assert!(!sniff_binary(b"")); -} - -#[test] -fn a_nul_in_the_first_8192_bytes_is_binary() { - assert!(sniff_binary(b"\0abc")); - let mut bytes = vec![b'a'; 9_000]; - bytes[8_191] = 0; - assert!(sniff_binary(&bytes), "index 8191 is inside the sample"); - let mut bytes = vec![b'a'; 9_000]; - bytes[8_192] = 0; - assert!(!sniff_binary(&bytes), "index 8192 is outside the sample"); -} - -#[test] -fn the_sniff_ignores_everything_past_the_sample() { - let mut bytes = vec![b'a'; 8_192]; - bytes.extend(vec![0x01; 8_192]); - assert!(!sniff_binary(&bytes)); -} - -#[test] -fn five_percent_control_bytes_is_binary_and_less_is_not() { - let sample = |controls: usize| { - let mut bytes = vec![b'a'; 1_000 - controls]; - bytes.extend(vec![0x01; controls]); - bytes - }; - assert!(sniff_binary(&sample(50)), "50 of 1000 is 5%"); - assert!(!sniff_binary(&sample(49)), "49 of 1000 is 4.9%"); - assert!(sniff_binary(&sample(1_000))); -} - -#[test] -fn a_delete_byte_counts_as_a_control_byte() { - let mut bytes = vec![b'a'; 950]; - bytes.extend(vec![0x7F; 50]); - assert!(sniff_binary(&bytes)); -} - -#[test] -fn tab_line_feed_and_carriage_return_never_count() { - let bytes: Vec = (0..1_000).map(|n| b"\t\n\r"[n % 3]).collect(); - assert!(!sniff_binary(&bytes)); -} - -#[test] -fn a_file_with_a_utf16_mark_skips_the_sniff_and_one_without_is_binary() { - assert!(!sniff_binary(&utf16_le("hello\n"))); - assert!(!sniff_binary(&utf16_be("hello\n"))); - assert!( - sniff_binary(&[b'h', 0, b'i', 0]), - "UTF-16 without a mark holds NUL bytes" - ); -} - -#[test] -fn a_final_newline_does_not_make_an_extra_line() { - assert_eq!(split_lines("a\nb"), ["a", "b"]); - assert_eq!(split_lines("a\nb\n"), ["a", "b"]); -} - -#[test] -fn an_empty_text_has_no_lines_and_a_lone_newline_has_one() { - assert!(split_lines("").is_empty()); - assert_eq!(split_lines("\n"), [""]); - assert_eq!(split_lines("a\n\nb\n"), ["a", "", "b"]); -} - -#[test] -fn one_trailing_carriage_return_is_stripped_from_each_line() { - assert_eq!(split_lines("a\r\nb\r\n"), ["a", "b"]); - assert_eq!(split_lines("a\r\nb\r"), ["a", "b"]); - assert_eq!(split_lines("\r\n"), [""]); - assert_eq!(split_lines("a\r\r\nb"), ["a\r", "b"]); -} - -#[test] -fn a_lone_carriage_return_inside_a_line_is_kept() { - assert_eq!( - split_lines("first\rNEEDLE\rthird"), - ["first\rNEEDLE\rthird"] - ); -} - -/// The text of one 1-byte, one 2-byte, one 3-byte, and one 4-byte -/// character, twice. -const MIXED: &str = "aé€😀aé€😀"; - -/// The smallest character boundary at or after `at`, within `text`. -fn boundary_up(text: &str, at: usize) -> usize { - (at.min(text.len())..=text.len()) - .find(|&n| text.is_char_boundary(n)) - .expect("the end of a string is a boundary") -} - -/// The largest character boundary at or before `at`, within `text`. -fn boundary_down(text: &str, at: usize) -> usize { - (0..=at.min(text.len())) - .rev() - .find(|&n| text.is_char_boundary(n)) - .expect("the start of a string is a boundary") -} - -#[test] -fn clip_gives_the_exact_range_when_both_ends_are_boundaries() { - assert_eq!(clip_to_boundary("hello", 1, 4), "ell"); - assert_eq!(clip_to_boundary("hello", 0, 5), "hello"); - assert_eq!(clip_to_boundary("hello", 2, 2), ""); -} - -#[test] -fn clip_rounds_the_start_up_and_the_end_down_over_every_range() { - for text in ["a", "é", "€", "😀", "aé€😀", MIXED] { - let len = text.len(); - for start in 0..=len + 2 { - for end in 0..=len + 2 { - let clipped = clip_to_boundary(text, start, end); - let low = boundary_up(text, start); - let high = boundary_down(text, end); - let expected = if low >= high { "" } else { &text[low..high] }; - assert_eq!(clipped, expected, "{text:?} from {start} to {end}"); - if !clipped.is_empty() { - let at = clipped.as_ptr().addr() - text.as_ptr().addr(); - assert!( - at >= start && at + clipped.len() <= end.min(len), - "{text:?} from {start} to {end} gave bytes {at}..{}", - at + clipped.len() - ); - } - } - } - } -} - -#[test] -fn clip_clamps_both_ends_to_the_length_and_a_reversed_range_is_empty() { - assert_eq!(clip_to_boundary("abc", 1, 100), "bc"); - assert_eq!(clip_to_boundary("abc", 100, 200), ""); - assert_eq!(clip_to_boundary("abc", 3, 1), ""); - assert_eq!(clip_to_boundary("", 0, 5), ""); -} - -#[test] -fn a_prefix_cut_never_splits_a_character() { - for width in 0..=MIXED.len() { - let kept = clip_to_boundary(MIXED, 0, width); - assert!(MIXED.starts_with(kept), "{width}"); - assert!(kept.len() <= width, "{width}"); - assert!(width - kept.len() < 4, "the cut stops within one character"); - } - assert_eq!(clip_to_boundary("€€", 0, 5), "€"); - assert_eq!(clip_to_boundary("€€", 0, 2), ""); -} diff --git a/crates/plugin-std/tests/it/grep.rs b/crates/plugin-std/tests/it/grep.rs deleted file mode 100644 index 6daaf2775..000000000 --- a/crates/plugin-std/tests/it/grep.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! Grep's cooperative yield, seen from the Pack's own future: a search over -//! many files hands control back to the executor before it completes, and -//! dropping it at a pause leaves the filesystem usable. - -use std::future::Future; -use std::pin::pin; -use std::task::{Context, Poll, Waker}; - -use runvil_plugin::testing::TestCall; -use runvil_plugin::{Error, OutputTrust, ToolOutput}; -use serde_json::{Value, json}; - -use crate::tools::{built, id}; - -/// How many files the yield check seeds. -const FILES: usize = 1000; - -/// A call with `FILES` small files, each holding one matching line. -fn seeded_call() -> TestCall { - let call = TestCall::new(id("grep")); - for index in 0..FILES { - call.context() - .access() - .write(&format!("/f{index:04}.txt"), b"x\n") - .expect("a seeded file is written"); - } - call -} - -/// The search arguments the yield check uses: one count row per file. -fn args() -> Value { - json!({ "pattern": "x", "output_mode": "count" }) -} - -#[test] -fn a_search_over_a_thousand_files_yields_to_the_executor_before_it_completes() { - let pack = built(); - let call = seeded_call(); - let mut future = pin!(pack.call(call.context(), args())); - let mut cx = Context::from_waker(Waker::noop()); - let mut pending = 0; - let result: Result = loop { - match future.as_mut().poll(&mut cx) { - Poll::Ready(result) => break result, - Poll::Pending => pending += 1, - } - }; - assert!( - pending >= 31, - "a pause after each 32 of {FILES} files, and the search paused {pending} times" - ); - let output = result.expect("the search completes with its result"); - assert_eq!(output.trust(), OutputTrust::Untrusted); - let rows: Vec<&str> = output.text().lines().collect(); - assert_eq!( - rows.len(), - FILES, - "every file has its row and there is no notice" - ); - assert_eq!(rows.first(), Some(&"/f0000.txt:1")); - assert_eq!(rows.last(), Some(&"/f0999.txt:1")); -} - -#[test] -fn dropping_a_search_after_its_first_pause_is_safe_and_the_access_still_works() { - let pack = built(); - let call = seeded_call(); - { - let mut dropped = pin!(pack.call(call.context(), args())); - let mut cx = Context::from_waker(Waker::noop()); - assert!( - dropped.as_mut().poll(&mut cx).is_pending(), - "the search pauses before it can finish" - ); - } - let bytes = call - .context() - .access() - .read("/f0000.txt") - .expect("the access still reads after the drop"); - assert_eq!(bytes, b"x\n"); - let mut again = pin!(pack.call(call.context(), args())); - let mut cx = Context::from_waker(Waker::noop()); - let output = loop { - if let Poll::Ready(result) = again.as_mut().poll(&mut cx) { - break result.expect("a fresh search runs to its result"); - } - }; - assert_eq!(output.text().lines().count(), FILES); -} diff --git a/crates/plugin-std/tests/it/main.rs b/crates/plugin-std/tests/it/main.rs deleted file mode 100644 index d97463dfb..000000000 --- a/crates/plugin-std/tests/it/main.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Integration tests for `plugin-std`: the Pack `install` inserts, the -//! tools its factory names under the Pack id it receives, and what a call -//! answers with arguments that match or miss the schema. -#![expect( - clippy::expect_used, - reason = "test helpers panic on setup failure, which is the desired behavior" -)] - -mod grep; -mod tools; diff --git a/crates/plugin-std/tests/it/tools.rs b/crates/plugin-std/tests/it/tools.rs deleted file mode 100644 index 8710592f8..000000000 --- a/crates/plugin-std/tests/it/tools.rs +++ /dev/null @@ -1,414 +0,0 @@ -//! The `std` Pack through the crate's `install`: the one Pack it inserts, -//! unavailable for any config but `null` or `{}`, the seven tools its -//! factory names under the Pack id it receives, and what a call answers. - -use std::future::Future; -use std::pin::pin; -use std::sync::Arc; -use std::task::{Context, Poll, Waker}; - -use plugin_std::{NOT_IMPLEMENTED, install}; -use runvil_plugin::testing::TestCall; -use runvil_plugin::{ - Error, ErrorCode, ErrorFact, HostServices, OutputTrust, Pack, PackFactory, PackId, PackInfo, - PackRegistry, ToolId, ToolOutput, -}; -use serde_json::{Value, json}; - -/// Drives a future that never pends to its output. -fn block_on(future: F) -> F::Output { - let mut future = pin!(future); - let mut cx = Context::from_waker(Waker::noop()); - loop { - if let Poll::Ready(output) = future.as_mut().poll(&mut cx) { - return output; - } - } -} - -/// The built-ins every fresh registry holds, which `install` does not -/// insert. -const BUILTINS: [&str; 2] = ["runvil/subagents", "runvil/user"]; - -/// The Pack `install` inserts into a fresh registry with `config`, and its -/// factory or the reason it was inserted unavailable. -fn inserted_with(config: Value) -> (PackInfo, Result, Error>) { - let mut packs = PackRegistry::new(); - install(&mut packs, config, &HostServices::new()).expect("a fresh registry takes the Pack"); - let (info, _id, factory) = packs - .into_entries() - .find(|(info, _, _)| !BUILTINS.contains(&info.fqn)) - .expect("install inserts one Pack"); - (info, factory) -} - -/// The Pack `install` inserts into a fresh registry. -fn std_pack() -> PackInfo { - inserted_with(Value::Null).0 -} - -/// Each Pack `install` inserts into a fresh registry with `config`, beside -/// its Pack id and the reason it was inserted unavailable, if it was. -fn inserted(config: Value) -> Vec<(String, String, Option)> { - let mut packs = PackRegistry::new(); - install(&mut packs, config, &HostServices::new()).expect("a fresh registry takes the Pack"); - packs - .into_entries() - .filter(|(info, _, _)| !BUILTINS.contains(&info.fqn)) - .map(|(info, id, factory)| { - let reason = factory.err().map(|error| error.to_string()); - (info.fqn.to_owned(), id.to_string(), reason) - }) - .collect() -} - -/// The Pack made under the Pack id `name` by the factory `install` inserts. -fn made(name: &str) -> Arc { - let name = PackId::parse(name).expect("a one-segment Pack id parses"); - let Ok(factory) = inserted_with(Value::Null).1 else { - panic!("install inserts std usable with no configuration"); - }; - let Ok(pack) = factory.new_pack(&name) else { - panic!("the factory makes the Pack"); - }; - pack -} - -/// The Pack made under `std`. -pub(crate) fn built() -> Arc { - made("std") -} - -/// The tool id `segment` under the Pack id `std`. -pub(crate) fn id(segment: &str) -> ToolId { - ToolId::parse(&format!("std/{segment}")).expect("a two-segment tool id parses") -} - -/// Calls the tool `segment` with `args`. -fn call(segment: &str, args: Value) -> Result { - call_seeded(segment, &[], args) -} - -/// Calls the tool `segment` with `args` after writing each of `files`, a -/// path beside its bytes, through the call's own access. -fn call_seeded(segment: &str, files: &[(&str, &[u8])], args: Value) -> Result { - let pack = built(); - let call = TestCall::new(id(segment)); - for (path, bytes) in files { - call.context() - .access() - .write(path, bytes) - .expect("a seeded file is written"); - } - block_on(pack.call(call.context(), args)) -} - -#[test] -fn the_pack_is_runvil_std_with_no_prelude() { - let info = std_pack(); - assert_eq!(info.fqn, "runvil/std"); - assert!(info.prelude.is_none()); - assert_eq!(info.description, "Read, write, search, and delete files"); -} - -#[test] -fn install_adds_the_std_pack_usable_when_its_config_is_null_or_an_empty_object() { - for config in [ - json!({ "std": {}, "web": { "x": 1 } }), - json!({ "web": {} }), - Value::Null, - ] { - let read = inserted(config.clone()); - assert_eq!( - read, - [("runvil/std".to_owned(), "std".to_owned(), None)], - "{config}" - ); - } -} - -#[test] -fn install_adds_the_std_pack_unavailable_for_any_other_configuration() { - for config in [ - json!(1), - json!({ "max_chars": 10 }), - json!([]), - json!("std"), - ] { - let read = inserted(json!({ "std": config })); - assert_eq!( - read, - [( - "runvil/std".to_owned(), - "std".to_owned(), - Some("std takes no configuration".to_owned()) - )], - "{config}" - ); - } -} - -#[test] -fn install_returns_the_registrys_refusal() { - let mut packs = PackRegistry::new(); - let services = HostServices::new(); - install(&mut packs, Value::Null, &services).expect("the first install inserts std"); - let error = - install(&mut packs, Value::Null, &services).expect_err("a second install is refused"); - assert_eq!(error.code, ErrorCode::InvalidValue); - assert_eq!( - error.facts.get(&ErrorFact::Reason), - Some(&Value::from("duplicate_pack")) - ); - assert_eq!( - error.facts.get(&ErrorFact::Name), - Some(&Value::from("runvil/std")) - ); -} - -#[test] -fn the_factory_names_seven_tools_under_the_pack_id_it_receives() { - let ids: Vec = made("files") - .tools() - .iter() - .map(|tool| tool.id.to_string()) - .collect(); - assert_eq!( - ids, - [ - "files/read", - "files/write", - "files/replace", - "files/rm", - "files/glob", - "files/grep", - "files/dir" - ] - ); -} - -#[test] -fn every_tool_carries_cursors_name_for_it_as_its_wire_name() { - let pack = built(); - let names: Vec<(String, Option)> = pack - .tools() - .iter() - .map(|tool| (tool.id.to_string(), tool.wire_name.clone())) - .collect(); - assert_eq!( - names, - [ - ("std/read".to_owned(), Some("Read".to_owned())), - ("std/write".to_owned(), Some("Write".to_owned())), - ("std/replace".to_owned(), Some("StrReplace".to_owned())), - ("std/rm".to_owned(), Some("Delete".to_owned())), - ("std/glob".to_owned(), Some("Glob".to_owned())), - ("std/grep".to_owned(), Some("Grep".to_owned())), - ("std/dir".to_owned(), Some("ListDir".to_owned())), - ] - ); -} - -#[test] -fn no_tool_survives_a_stop_or_reports_structured_output() { - for tool in built().tools() { - assert!(!tool.survives_stop, "{}: {}", tool.id, tool.survives_stop); - assert!( - !tool.structured_output, - "{}: {}", - tool.id, tool.structured_output - ); - assert!(!tool.describes_tools, "{}", tool.id); - } -} - -#[test] -fn every_tool_answers_the_not_implemented_text_naming_itself() { - for (segment, wire_name) in [ - ("write", "Write"), - ("replace", "StrReplace"), - ("rm", "Delete"), - ("glob", "Glob"), - ] { - let args = match segment { - "rm" => json!({ "path": "/notes.md" }), - "write" => json!({ "path": "/notes.md", "contents": "hi" }), - "replace" => json!({ - "path": "/notes.md", - "old_string": "a", - "new_string": "b" - }), - _ => json!({ "glob_pattern": "**/*.md" }), - }; - let output = call(segment, args).expect("a matching call succeeds"); - let expected = NOT_IMPLEMENTED.replace("", wire_name); - assert_eq!(output.text(), expected, "{segment}"); - assert_eq!(output.trust(), OutputTrust::Trusted, "{segment}"); - } -} - -#[test] -fn the_not_implemented_text_says_the_result_never_changes() { - let text = NOT_IMPLEMENTED.replace("", "Read"); - assert!( - text.contains("nothing in this session will change that"), - "{text}" - ); - assert!(text.contains("Calling Read again is safe"), "{text}"); - assert!(text.contains("this same result every time"), "{text}"); - assert!(!text.contains('<'), "the name is filled in: {text}"); -} - -#[test] -fn a_missing_required_parameter_fails_naming_it() { - let error = call("grep", json!({})).expect_err("pattern is required"); - assert_eq!( - error.to_string(), - "std_Grep: `pattern` is required, and the call left it out" - ); - assert_eq!(error.code, ErrorCode::ToolInvalidArguments); -} - -#[test] -fn a_parameter_of_the_wrong_type_fails_naming_it() { - let error = call("read", json!({ "path": 7 })).expect_err("path is a string"); - assert_eq!( - error.to_string(), - "std_Read: `path` must be a JSON string, and the call passed 7" - ); - assert_eq!(error.code, ErrorCode::ToolInvalidArguments); -} - -#[test] -fn a_value_outside_an_enum_fails_listing_the_allowed_values() { - let error = call("grep", json!({ "pattern": "x", "output_mode": "lines" })) - .expect_err("output_mode has three allowed values"); - assert_eq!( - error.to_string(), - "std_Grep: `output_mode` must be one of \"content\", \"files_with_matches\", \"count\", \ - and the call passed \"lines\"" - ); - assert_eq!(error.code, ErrorCode::ToolInvalidArguments); -} - -#[test] -fn a_call_of_a_tool_the_pack_lacks_fails_naming_it() { - let pack = built(); - let unknown = TestCall::new(id("copy")); - let error = - block_on(pack.call(unknown.context(), json!({}))).expect_err("std has no copy tool"); - assert_eq!(error.to_string(), "std has no tool named copy"); -} - -#[test] -fn read_answers_the_numbered_lines_of_a_seeded_file_as_untrusted_output() { - let output = call_seeded( - "read", - &[("/notes.md", b"hello\nworld\n")], - json!({ "path": "/notes.md" }), - ) - .expect("a seeded file reads"); - assert_eq!(output.text(), " 1|hello\n 2|world\n"); - assert_eq!(output.trust(), OutputTrust::Untrusted); -} - -#[test] -fn list_dir_answers_the_tree_of_a_seeded_directory_as_untrusted_output() { - let output = call_seeded( - "dir", - &[ - ("/proj/README.md", b"hello"), - ("/proj/src/lib.rs", b"fn main() {}"), - ("/proj/.git/HEAD", b"ref"), - ], - json!({ "target_directory": "/proj" }), - ) - .expect("a seeded directory lists"); - assert_eq!( - output.text(), - "/proj/\n - README.md\n - src/\n - lib.rs" - ); - assert_eq!(output.trust(), OutputTrust::Untrusted); -} - -#[test] -fn list_dir_of_a_missing_path_fails_with_the_does_not_exist_text() { - let error = - call("dir", json!({ "target_directory": "/nope" })).expect_err("the directory is missing"); - assert_eq!(error.to_string(), "Path does not exist: /nope"); -} - -#[test] -fn list_dir_leaves_out_what_ignore_globs_match() { - let output = call_seeded( - "dir", - &[("/proj/a.md", b"x"), ("/proj/b.log", b"y")], - json!({ "target_directory": "/proj", "ignore_globs": ["*.log"] }), - ) - .expect("a seeded directory lists"); - assert_eq!(output.text(), "/proj/\n - a.md"); -} - -#[test] -fn read_answers_file_is_empty_for_a_zero_byte_file() { - let output = call_seeded( - "read", - &[("/empty.md", b"")], - json!({ "path": "/empty.md" }), - ) - .expect("an empty file reads"); - assert_eq!(output.text(), "File is empty."); - assert_eq!(output.trust(), OutputTrust::Untrusted); -} - -#[test] -fn read_of_a_missing_path_fails_with_the_not_found_text() { - let error = call("read", json!({ "path": "/nope.md" })).expect_err("the file is missing"); - assert_eq!(error.to_string(), "File not found: /nope.md"); -} - -#[test] -fn grep_answers_the_matching_lines_of_a_seeded_tree_as_untrusted_output() { - let output = call_seeded( - "grep", - &[ - ( - "/src/a.ts", - b"// one\n// two\nconst alpha = 1\nlet beta = 2\n// five\n// six\n// seven\n\ - // eight\nconst gamma = 3\nexport default gamma\n", - ), - ("/src/b.ts", b"const first = 0\n"), - ], - json!({ "pattern": "const", "-A": 1 }), - ) - .expect("a seeded tree searches"); - assert_eq!( - output.text(), - "/src/a.ts\n 3:const alpha = 1\n 4-let beta = 2\n --\n 9:const gamma = 3\n \ - 10-export default gamma\n\n/src/b.ts\n 1:const first = 0" - ); - assert_eq!(output.trust(), OutputTrust::Untrusted); -} - -#[test] -fn grep_of_a_missing_path_fails_with_the_does_not_exist_text() { - let error = - call("grep", json!({ "pattern": "x", "path": "/nope" })).expect_err("the path is missing"); - assert_eq!(error.to_string(), "Path does not exist: /nope"); -} - -#[test] -fn grep_refuses_an_undeclared_key_by_name_while_the_stubs_let_it_through() { - let error = call("grep", json!({ "pattern": "x", "context": 3 })) - .expect_err("context is not a parameter"); - assert_eq!(error.code, ErrorCode::ToolInvalidArguments); - assert!( - error - .to_string() - .starts_with("std_Grep: `context` is not a parameter of this tool."), - "{error}" - ); - let stub = call("glob", json!({ "glob_pattern": "*.md", "extra": 1 })) - .expect("a stub ignores an undeclared key"); - assert_eq!(stub.text(), NOT_IMPLEMENTED.replace("", "Glob")); -} diff --git a/crates/runvil-plugin/src/lib.rs b/crates/runvil-plugin/src/lib.rs index 8cd35dc5d..af7d715f1 100644 --- a/crates/runvil-plugin/src/lib.rs +++ b/crates/runvil-plugin/src/lib.rs @@ -24,8 +24,9 @@ //! point that follows the C contract in the `runvil-plugin-sys` crate: a //! dynamic library exports it as `runvil_plugin`, or a Plugin crate links //! it in under a name of its own. -//! The Harness loads these entry points when a Host inserts them; they -//! insert no Packs yet. +//! The Harness loads these entry points when a Host inserts them, and +//! inserts each Pack they offer with a factory that reaches the Pack +//! through its entry point. //! Every registry holds two built-in Packs: `runvil/subagents`, which //! reserves its id for the Engine's subagent tools, and `runvil/user`, //! which a prompt that needs a human declares and which the Harness diff --git a/crates/runvil-plugin/src/registry-tests.rs b/crates/runvil-plugin/src/registry-tests.rs index 83a76a74e..871ea706a 100644 --- a/crates/runvil-plugin/src/registry-tests.rs +++ b/crates/runvil-plugin/src/registry-tests.rs @@ -67,10 +67,10 @@ fn a_new_registry_holds_the_builtins_and_lists_every_pack_in_fqn_order() { assert_eq!( listed, [ - ("acme/alpha", PackOrigin::Linked), + ("acme/alpha", PackOrigin::Plugin), (SUBAGENTS, PackOrigin::Builtin), (USER, PackOrigin::Builtin), - ("tests/zeta", PackOrigin::Linked), + ("tests/zeta", PackOrigin::Plugin), ] ); for (info, origin) in packs.list() { @@ -219,7 +219,7 @@ fn the_registrys_debug_output_shows_each_fqn_origin_and_pack_id() { let mut packs = PackRegistry::new(); packs.insert(fixture("acme/mcp"), builtin).unwrap(); let shown = format!("{packs:?}"); - for part in ["acme/mcp", "Linked", SUBAGENTS, USER, "Builtin"] { + for part in ["acme/mcp", "Plugin", SUBAGENTS, USER, "Builtin"] { assert!(shown.contains(part), "{part} is shown: {shown}"); } for id in [name("mcp"), name("subagents"), name("user")] { diff --git a/crates/runvil-plugin/src/registry.rs b/crates/runvil-plugin/src/registry.rs index b5fa0178b..eb2fe04d5 100644 --- a/crates/runvil-plugin/src/registry.rs +++ b/crates/runvil-plugin/src/registry.rs @@ -53,8 +53,11 @@ pub enum PackOrigin { /// has none yet, and `runvil/user`, which marks a prompt that needs a /// human. Builtin, - /// Inserted by a linked Plugin with [`PackRegistry::insert`]. - Linked, + /// Inserted by a Plugin with [`PackRegistry::insert`] or + /// [`PackRegistry::insert_unavailable`]: by a linked Plugin's + /// `install`, or by the Harness for a Plugin's C entry point, linked + /// or from a dynamic library. + Plugin, } impl Default for PackRegistry { @@ -84,7 +87,7 @@ impl PackRegistry { PackRegistry::default() } - /// Adds the Pack `info` names as a linked Pack, made by `factory`. + /// Adds the Pack `info` names as a Plugin's Pack, made by `factory`. /// /// The Pack id is the FQN's second segment, so `runvil/web` is `web`, /// and the registry may hold Packs of different vendors with the same @@ -113,7 +116,7 @@ impl PackRegistry { self.add(info, Ok(Arc::new(factory))) } - /// Adds the Pack `info` names as a linked Pack that is unavailable for + /// Adds the Pack `info` names as a Plugin's Pack that is unavailable for /// `reason`, such as bad config or a missing Host-wide service. /// `Host::parse` refuses a prompt that declares it, naming `reason`. /// @@ -124,7 +127,7 @@ impl PackRegistry { self.add(info, Err(reason)) } - /// Adds `info` as a linked Pack with `factory`, or refuses it as + /// Adds `info` as a Plugin's Pack with `factory`, or refuses it as /// [`insert`](PackRegistry::insert) documents. fn add( &mut self, @@ -167,7 +170,7 @@ impl PackRegistry { info.fqn, Registered { info, - origin: PackOrigin::Linked, + origin: PackOrigin::Plugin, id, factory, }, diff --git a/crates/runvil/public-api.txt b/crates/runvil/public-api.txt index 61f161f70..f877a8b58 100644 --- a/crates/runvil/public-api.txt +++ b/crates/runvil/public-api.txt @@ -1061,7 +1061,7 @@ pub runvil::plugin::PackInfo::description: &'static str pub runvil::plugin::PackInfo::fqn: &'static str pub runvil::plugin::PackInfo::prelude: core::option::Option<&'static str> pub runvil::plugin::PackOrigin::Builtin -pub runvil::plugin::PackOrigin::Linked +pub runvil::plugin::PackOrigin::Plugin pub runvil::plugin::RenderContext::entry: &'a str pub runvil::plugin::RenderContext::model: &'a runvil::model::ModelInfo pub runvil::prompt::ArgType::Boolean diff --git a/crates/workshop/desktop/AGENTS.md b/crates/workshop/desktop/AGENTS.md index 146392eca..2228a5846 100644 --- a/crates/workshop/desktop/AGENTS.md +++ b/crates/workshop/desktop/AGENTS.md @@ -6,3 +6,4 @@ - The desktop app never replaces the Gateway's files. The Gateway it ships is the release `gateway-pin/pin.json` names, from `cppalliance/runvil-gateway`. - In `installer.nsi`, the Speech to Text section has no payload: `-Finalize` records the choice as the `InstallSTT` value the Gateway reads when it writes its first config, and never under `/UPDATE`. `/COMPONENTS=` and the exit code 2 (a refused component list) exist for CI; `.github/installers/cell.ps1` asserts them, so a change to either updates it. - `tauri.unsigned.conf.json` is for local builds without `--sign` only; every CI build signs. +- Workshop never links the std Plugin. It loads it as a dynamic library from the Plugin directory (`installed_plugin_dir`): `plugins` beside Workshop on Windows and in a development build, and inside the macOS bundle and the AppImage, so the updater replaces the library with Workshop. A change to the library's name or place updates every file that names it: `crates/workshop/gateway-discovery/src/peer.rs`, which finds the directory; `installer.nsi`, which installs the library with the Workshop component and removes it with it; `crates/build-workshop/src/main/plugin.rs`, which places it for a development build and has Tauri copy it into the macOS and Linux bundles; `.github/installers/cell.ps1` and `cell.sh`, which assert it and run `runvil-workshop --plugins`; and the server's tests, in `crates/workshop/server/tests/common/mod.rs` and `crates/workshop/server/src/agents/tests.rs`. diff --git a/crates/workshop/desktop/README.md b/crates/workshop/desktop/README.md index dc4db5c30..ca4f89698 100644 --- a/crates/workshop/desktop/README.md +++ b/crates/workshop/desktop/README.md @@ -12,10 +12,26 @@ The Runvil installer offers Workshop and the gateway as separate components, and On every platform Workshop finds the gateway beside its own install through `workshop-gateway-discovery`'s installed-peer lookup. +## Plugins + +Workshop loads every library Plugin in its Plugin directory when it starts. The directory is named `plugins` and ships with Workshop itself, so whatever replaces Workshop replaces its Plugins too: + +- Windows: beside `runvil-workshop.exe`. +- macOS: inside the bundle, at `Runvil.app/Contents/Resources/plugins`. +- Linux: inside the AppImage, at `usr/lib/runvil/plugins`. + +`[agents] plugins` in `workshop.toml` names another directory instead. + +Each installer puts one library there, the std Plugin, which holds the file tools the built-in chat agent declares: `plugin_std.dll`, `libplugin_std.dylib`, or `libplugin_std.so`. Its code lives in [cppalliance/runvil-plugin-std](https://github.com/cppalliance/runvil-plugin-std), pinned by commit in the root `Cargo.toml`. A Workshop with no Plugin directory, or one whose std library does not load, refuses an agent that requires `std` and says that `runvil/std` is not installed. + +`runvil-workshop --plugins` prints each Plugin the installation loads, as its name and its version, and exits. A library that does not load goes to stderr with the reason. Workshop keeps no log, so this is the way to see why an agent has no file tools. + ## Updates Workshop checks for an update a few seconds after it starts and from Help, About, through the Tauri updater and the `latest.json` on the `workshop-latest` release, whose files are signed. On macOS and Linux it replaces its own `.app` or AppImage; on Windows it runs the new setup, which updates every installed component, the gateway included. +An update replaces the Plugin directory with Workshop on every platform, because the directory sits inside what the updater replaces: the `.app`, the AppImage, or the files the Windows setup installs. A Plugin loads only in a Workshop that speaks its exact contract version, so the two must change together. + ## Building `cargo workshop` builds this app for development around the pinned gateway, and `cargo workshop installer` builds a platform's installer; `crates/build-workshop/README.md` covers both. diff --git a/crates/workshop/desktop/installer.nsi b/crates/workshop/desktop/installer.nsi index 526f69eb1..e7a7d7315 100644 --- a/crates/workshop/desktop/installer.nsi +++ b/crates/workshop/desktop/installer.nsi @@ -58,6 +58,15 @@ ${StrLoc} !define UNINSTALLERHEADERIMAGE "{{uninstaller_header_image}}" !define MAINBINARYNAME "{{main_binary_name}}" !define MAINBINARYSRCPATH "{{main_binary_path}}" +; The std Plugin's library. Cargo writes it to `deps` beside the main +; executable whenever it builds Workshop, so its path is the main +; executable's with the file name replaced. +!searchreplace STDPLUGINSRCPATH "${MAINBINARYSRCPATH}" "${MAINBINARYNAME}.exe" "deps\plugin_std.dll" +; A main executable under another name would leave the path unchanged, and +; the setup would install the executable as the Plugin. +!if "${STDPLUGINSRCPATH}" == "${MAINBINARYSRCPATH}" + !error "cannot find the std Plugin: ${MAINBINARYSRCPATH} does not end in ${MAINBINARYNAME}.exe" +!endif !define BUNDLEID "{{bundle_id}}" !define COPYRIGHT "{{copyright}}" !define OUTFILE "{{out_file}}" @@ -734,6 +743,11 @@ Section "Runvil Workshop" SecWorkshop ; Copy main executable File "${MAINBINARYSRCPATH}" + ; Copy the std Plugin's library into the Plugin directory, `plugins` + ; beside the main executable, where Workshop loads library Plugins from + CreateDirectory "$INSTDIR\plugins" + File "/oname=plugins\plugin_std.dll" "${STDPLUGINSRCPATH}" + ; Copy resources {{#each resources_dirs}} CreateDirectory "$INSTDIR\\{{this}}" @@ -884,6 +898,11 @@ Section "-Finalize" ; payloads of declined components or they would linger. !insertmacro DeleteComponentPayloadIfDeclined ${SecGateway} $INSTDIR\runvil-gateway.exe !insertmacro DeleteComponentPayloadIfDeclined ${SecWorkshop} $INSTDIR\${MAINBINARYNAME}.exe + !insertmacro DeleteComponentPayloadIfDeclined ${SecWorkshop} $INSTDIR\plugins\plugin_std.dll + ; Without /r, RMDir removes the Plugin directory only when it is empty, + ; which it is only after the line above deleted the one library in it. + RMDir "$INSTDIR\plugins" + ClearErrors SectionEnd @@ -961,6 +980,11 @@ Section "un.Workshop" ; Delete the main executable Delete "$INSTDIR\${MAINBINARYNAME}.exe" + ; Delete the std Plugin's library, and the Plugin directory unless a + ; library the installer did not put there is left in it + Delete "$INSTDIR\plugins\plugin_std.dll" + RMDir "$INSTDIR\plugins" + ; Delete resources {{#each resources}} Delete "$INSTDIR\\{{this.[1]}}" diff --git a/crates/workshop/desktop/src/config.rs b/crates/workshop/desktop/src/config.rs index b9b8ef87a..c4e9493d8 100644 --- a/crates/workshop/desktop/src/config.rs +++ b/crates/workshop/desktop/src/config.rs @@ -1,5 +1,6 @@ -//! The desktop app's workshop-server configuration: `workshop.toml` discovery -//! and the forced ephemeral loopback bind. +//! The desktop app's workshop-server configuration: `workshop.toml` discovery, +//! the forced ephemeral loopback bind, and the installation's Plugin +//! directory. //! //! The desktop app runs the workshop server in-process, so the listener //! settings are the desktop app's own: the bind is always `127.0.0.1:0` (an @@ -7,7 +8,10 @@ //! single-instance handoff cannot close). A discovered `workshop.toml` still //! owns the `[gateway]` connection settings and the state and //! agent prompt paths; the gateway endpoint itself resolves inside the -//! server, gateway discovery file first, explicit config second. +//! server, gateway discovery file first, explicit config second. A config +//! that names no Plugin directory gets the installation's own, `plugins` +//! where the installer lays it out: beside Workshop on Windows, and +//! inside Workshop's bundle on macOS and Linux. use std::path::{Path, PathBuf}; @@ -32,19 +36,27 @@ const DESKTOP_BIND: &str = "127.0.0.1:0"; /// resolution, which attaches through the gateway discovery /// file or fails plainly. /// +/// Either way, a config that names no Plugin directory gets the +/// installation's own. +/// /// # Errors /// Returns an error when a discovered file cannot be loaded or the /// executable or current directory cannot be determined. pub(crate) fn load() -> anyhow::Result { - let exe_dir = std::env::current_exe() - .context("locate the executable") - .and_then(|exe| { - exe.parent() - .map(Path::to_path_buf) - .context("the executable has no parent directory") - })?; + let exe = std::env::current_exe().context("locate the executable")?; + let exe_dir = exe + .parent() + .context("the executable has no parent directory")?; let cwd = std::env::current_dir().context("locate the current directory")?; - load_in(&exe_dir, &cwd, std::env::home_dir().as_deref()) + let appimage = workshop_server_api::discovery::running_appimage( + &exe, + std::env::var_os("APPIMAGE").as_deref(), + std::env::var_os("APPDIR").as_deref(), + ); + let plugins = workshop_server_api::discovery::installed_plugin_dir(&exe, appimage.as_deref()); + let mut config = load_in(exe_dir, &cwd, std::env::home_dir().as_deref())?; + name_plugin_dir(&mut config, plugins); + Ok(config) } /// The testable core of [`load`], with the three base directories @@ -64,6 +76,14 @@ fn load_in(exe_dir: &Path, cwd: &Path, home: Option<&Path>) -> anyhow::Result) { + if config.agents.plugins.is_none() { + config.agents.plugins = installed; + } +} + /// Builds the candidate list in search order from the three base /// directories. fn candidates_from(exe_dir: &Path, cwd: &Path, home: Option<&Path>) -> Vec { @@ -215,6 +235,29 @@ mod tests { assert_eq!(config.agents.path, profile_dir(&home).join("agents")); } + #[test] + fn a_config_that_names_no_plugin_directory_gets_the_installations() { + let (_temp, exe, cwd, home) = roots(); + let installed = exe.join("plugins"); + let mut config = load_in(&exe, &cwd, Some(&home)).expect("the default config"); + assert_eq!(config.agents.plugins, None); + name_plugin_dir(&mut config, Some(installed.clone())); + assert_eq!(config.agents.plugins, Some(installed.clone())); + + std::fs::write( + exe.join(CONFIG_FILE_NAME), + "[gateway]\nbase_url = \"http://x\"\napi_key = \"k\"\n\n[agents]\nplugins = \"mine\"\n", + ) + .expect("write fixture"); + let mut config = load_in(&exe, &cwd, Some(&home)).expect("the discovered file loads"); + name_plugin_dir(&mut config, Some(installed)); + assert_eq!( + config.agents.plugins, + Some(PathBuf::from("mine")), + "a Plugin directory the file names is kept" + ); + } + #[test] fn no_file_and_no_home_anchors_at_the_working_directory() { let (_temp, exe, cwd, _home) = roots(); diff --git a/crates/workshop/desktop/src/main.rs b/crates/workshop/desktop/src/main.rs index 6c1d0aeac..1b70614c9 100644 --- a/crates/workshop/desktop/src/main.rs +++ b/crates/workshop/desktop/src/main.rs @@ -89,6 +89,9 @@ fn main() -> ExitCode { if std::env::args_os().any(|arg| arg == "--version") { return print_version(); } + if std::env::args_os().any(|arg| arg == "--plugins") { + return print_plugins(); + } match run() { Ok(()) => ExitCode::SUCCESS, Err(error) => { @@ -107,6 +110,44 @@ fn print_version() -> ExitCode { ExitCode::SUCCESS } +/// `runvil-workshop --plugins`: print each library Plugin this +/// installation loads, as its name and its version, and exit, without +/// booting the server or opening a window. A library that does not load +/// goes to stderr with the reason. Workshop keeps no log, so this is how +/// a person sees why an agent has no file tools, and the release +/// workflows check the installed std Plugin with it. +fn print_plugins() -> ExitCode { + let directory = match config::load() { + Ok(config) => config.agents.plugins, + Err(error) => { + eprintln!("{error:?}"); + return ExitCode::FAILURE; + } + }; + let Some(directory) = directory else { + eprintln!("this installation names no Plugin directory"); + return ExitCode::FAILURE; + }; + match workshop_server_api::list_plugin_dir(&directory) { + Ok(listing) => { + for line in &listing.loaded { + println!("{line}"); + } + for line in &listing.failed { + eprintln!("{line}"); + } + ExitCode::SUCCESS + } + Err(error) => { + eprintln!( + "cannot read the Plugin directory {}: {error}", + directory.display() + ); + ExitCode::FAILURE + } + } +} + fn update_supported(os: &str, appimage: Option<&OsStr>) -> bool { os != "linux" || appimage.is_some_and(|value| !value.is_empty()) } diff --git a/crates/workshop/gateway-discovery/src/lib.rs b/crates/workshop/gateway-discovery/src/lib.rs index 6c56ab586..762ba3b97 100644 --- a/crates/workshop/gateway-discovery/src/lib.rs +++ b/crates/workshop/gateway-discovery/src/lib.rs @@ -29,6 +29,8 @@ //! //! Before any Gateway runs, Workshop finds the installed one it launches //! with [`installed_gateway`], which knows each installer's layout. +//! [`installed_plugin_dir`] names Workshop's Plugin directory, which +//! ships inside Workshop's own bundle where the platform has one. //! //! URLs normalize to a literal `127.0.0.1`, never `localhost`, and probes //! send the bound address as the `Host` header, matching the Gateway's @@ -87,7 +89,8 @@ pub use crate::paths::{ lock_file_path, run_dir, }; pub use crate::peer::{ - GATEWAY_BUNDLE_NAME, WORKSHOP_APPIMAGE_NAME, WORKSHOP_BUNDLE_NAME, installed_gateway, + APPIMAGE_PLUGIN_PARENT, GATEWAY_BUNDLE_NAME, MACOS_PLUGIN_PARENT, PLUGIN_DIR_NAME, + WORKSHOP_APPIMAGE_NAME, WORKSHOP_BUNDLE_NAME, installed_gateway, installed_plugin_dir, running_appimage, }; pub use crate::shutdown::{ShutdownError, request_shutdown, request_shutdown_before}; diff --git a/crates/workshop/gateway-discovery/src/peer-tests.rs b/crates/workshop/gateway-discovery/src/peer-tests.rs index b0cb2dfd5..adc63843a 100644 --- a/crates/workshop/gateway-discovery/src/peer-tests.rs +++ b/crates/workshop/gateway-discovery/src/peer-tests.rs @@ -2,8 +2,9 @@ use std::ffi::OsStr; use std::path::{Path, PathBuf}; use super::{ - GATEWAY_BUNDLE_NAME, Layout, WORKSHOP_APPIMAGE_NAME, WORKSHOP_BUNDLE_NAME, app_bundle, - bundle_exe, first_file, gateway_candidates, installed_gateway, running_appimage, + GATEWAY_BUNDLE_NAME, Layout, PLUGIN_DIR_NAME, WORKSHOP_APPIMAGE_NAME, WORKSHOP_BUNDLE_NAME, + app_bundle, bundle_exe, first_file, gateway_candidates, installed_gateway, + installed_plugin_dir, plugin_dir, running_appimage, }; /// Plants an empty executable file at `root/relative`, creating its @@ -256,3 +257,89 @@ fn a_missing_install_directory_is_none() { let absent = root.path().join("absent").join("runvil-gateway"); assert_eq!(installed_gateway(&absent, None), None); } + +#[test] +fn the_plugin_directory_sits_beside_an_unbundled_workshop_in_every_layout() { + let install = Path::new("install"); + for layout in [Layout::Windows, Layout::MacOs, Layout::Linux] { + assert_eq!( + plugin_dir(layout, &install.join("runvil-workshop"), None), + Some(install.join(PLUGIN_DIR_NAME)), + "{layout:?}" + ); + } +} + +#[test] +fn the_macos_plugin_directory_sits_inside_the_workshop_bundle() { + let install = Path::new("Applications").join("Runvil"); + let workshop = bundle_exe(&install, WORKSHOP_BUNDLE_NAME, "runvil-workshop"); + assert_eq!( + plugin_dir(Layout::MacOs, &workshop, None), + Some( + install + .join(WORKSHOP_BUNDLE_NAME) + .join("Contents") + .join("Resources") + .join(PLUGIN_DIR_NAME) + ), + "the updater replaces the bundle, so the Plugins go with it" + ); + assert_eq!( + plugin_dir(Layout::Windows, &workshop, None), + Some( + install + .join(WORKSHOP_BUNDLE_NAME) + .join("Contents") + .join("MacOS") + .join(PLUGIN_DIR_NAME) + ), + "only the macOS layout reads the bundle structure" + ); +} + +#[test] +fn the_linux_plugin_directory_sits_inside_the_appimage_s_mount() { + let install = Path::new("opt").join("Runvil"); + let appimage = install.join(WORKSHOP_APPIMAGE_NAME); + let workshop = Path::new("mount") + .join("usr") + .join("bin") + .join("runvil-workshop"); + assert_eq!( + plugin_dir(Layout::Linux, &workshop, Some(appimage.as_path())), + Some( + Path::new("mount") + .join("usr") + .join("lib") + .join("runvil") + .join(PLUGIN_DIR_NAME) + ), + "the updater replaces the AppImage, so the Plugins go with it" + ); + assert_eq!( + plugin_dir(Layout::Linux, &workshop, None), + Some( + Path::new("mount") + .join("usr") + .join("bin") + .join(PLUGIN_DIR_NAME) + ), + "outside an AppImage the directory sits beside the executable" + ); + assert_eq!( + plugin_dir(Layout::Windows, &workshop, Some(appimage.as_path())), + Some( + Path::new("mount") + .join("usr") + .join("bin") + .join(PLUGIN_DIR_NAME) + ), + "only the Linux layout reads the AppImage" + ); +} + +#[test] +fn an_executable_without_a_directory_has_no_plugin_directory() { + assert_eq!(installed_plugin_dir(Path::new(""), None), None); +} diff --git a/crates/workshop/gateway-discovery/src/peer.rs b/crates/workshop/gateway-discovery/src/peer.rs index 80bb87e95..bfe4ef195 100644 --- a/crates/workshop/gateway-discovery/src/peer.rs +++ b/crates/workshop/gateway-discovery/src/peer.rs @@ -1,4 +1,5 @@ -//! Installed-peer lookup: where Workshop finds the Gateway it launches. +//! Installed-peer lookup: where Workshop finds the Gateway it launches and +//! the Plugin directory it loads. //! //! The installers lay the two programs out three ways: //! @@ -11,6 +12,12 @@ //! confirms Workshop runs from that AppImage, and looks there //! before its own directory: a gateway inside the mount loses its files //! when Workshop exits and the mount goes away. +//! +//! The Plugin directory, `plugins`, ships with Workshop itself, so that +//! whatever replaces Workshop replaces its Plugins too. On Windows and in +//! a development build it sits beside Workshop's executable. On macOS it +//! sits inside `Runvil.app`, and on Linux inside the AppImage, because +//! the updater replaces only that bundle or that file. use std::ffi::OsStr; use std::path::{Path, PathBuf}; @@ -27,6 +34,18 @@ pub const GATEWAY_BUNDLE_NAME: &str = "Runvil Gateway.app"; /// The Linux Workshop AppImage's file name. pub const WORKSHOP_APPIMAGE_NAME: &str = "Runvil.AppImage"; +/// The Plugin directory's name: the directory of dynamic libraries +/// Workshop loads as Plugins. +pub const PLUGIN_DIR_NAME: &str = "plugins"; + +/// The directory that holds the Plugin directory inside the macOS +/// Workshop bundle, from the bundle's `Contents`. +pub const MACOS_PLUGIN_PARENT: &str = "Resources"; + +/// The directory that holds the Plugin directory inside the Linux +/// AppImage, from the root of its mount. +pub const APPIMAGE_PLUGIN_PARENT: &str = "usr/lib/runvil"; + /// The installer layout a lookup searches. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[cfg_attr( @@ -136,6 +155,37 @@ fn gateway_candidates( candidates } +/// Names the Plugin directory of the installation the Workshop at +/// `workshop_exe` runs from, inside `appimage` when [`running_appimage`] +/// found one: `plugins` inside the AppImage's mount on Linux, inside +/// `Runvil.app` on macOS, and otherwise beside `workshop_exe`, as on +/// Windows and in a development tree. It matches the path shape only and +/// never touches the disk, so the directory it names may not exist. +/// `None` when `workshop_exe` has no parent directory. +#[must_use] +pub fn installed_plugin_dir(workshop_exe: &Path, appimage: Option<&Path>) -> Option { + plugin_dir(Layout::CURRENT, workshop_exe, appimage) +} + +fn plugin_dir(layout: Layout, workshop_exe: &Path, appimage: Option<&Path>) -> Option { + let beside = workshop_exe.parent()?; + let inside = match layout { + Layout::Windows => None, + Layout::MacOs => { + app_bundle(workshop_exe).map(|app| app.join("Contents").join(MACOS_PLUGIN_PARENT)) + } + // Workshop runs from `usr/bin` in the AppImage's mount. + Layout::Linux => appimage + .and_then(|_| beside.parent()?.parent()) + .map(|mount| mount.join(APPIMAGE_PLUGIN_PARENT)), + }; + Some( + inside + .unwrap_or_else(|| beside.to_path_buf()) + .join(PLUGIN_DIR_NAME), + ) +} + /// The `.app` bundle `exe` runs from, when `exe` sits at /// `.app/Contents/MacOS/`, or `None` for an unbundled executable /// such as a development build. Matches the path shape only and never diff --git a/crates/workshop/plugin-std/Cargo.toml b/crates/workshop/plugin-std/Cargo.toml new file mode 100644 index 000000000..ad545d563 --- /dev/null +++ b/crates/workshop/plugin-std/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "workshop-plugin-std" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +# A dynamic library, which is the file Workshop's Plugin directory holds, +# and an rlib, so the server can depend on this crate and have Cargo +# build the library with every build of Workshop. The library is named +# for the Plugin, so the file is `plugin_std.dll`, `libplugin_std.so`, or +# `libplugin_std.dylib`. +[lib] +name = "plugin_std" +crate-type = ["cdylib", "rlib"] + +[dependencies] +# The std Plugin, linked in with its own export off. +std-plugin.workspace = true +# `export_plugin!`, which exports the Plugin's entry point from this +# library. +runvil-plugin-sdk.workspace = true + +[lints] +workspace = true diff --git a/crates/workshop/plugin-std/src/lib.rs b/crates/workshop/plugin-std/src/lib.rs new file mode 100644 index 000000000..a89d2da65 --- /dev/null +++ b/crates/workshop/plugin-std/src/lib.rs @@ -0,0 +1,27 @@ +//! `workshop-plugin-std` - the std Plugin as the dynamic library Workshop +//! ships in its Plugin directory: `plugin_std.dll` on Windows, +//! `libplugin_std.so` on Linux, and `libplugin_std.dylib` on macOS. +//! +//! The Plugin's code lives in its own repository, +//! `cppalliance/runvil-plugin-std`, which the root `Cargo.toml` pins by +//! commit. Cargo gives a git dependency's own library a file name with a +//! hash in it, which no test and no installer could find, so this crate +//! links the Plugin in and exports its entry point under a file name the +//! workspace fixes. Cargo writes that file to `deps` in the profile's +//! directory whenever it builds the server, and `cargo workshop` copies +//! it from there into the Plugin directory. +//! +//! ## Invariants +//! +//! - Tier: library Plugin; may depend on no `workshop-*` crate, and only +//! the server may name it. `cargo test -p build-xtask` enforces the +//! tiers. +//! - Holds no code of its own beyond the export of `runvil_plugin`. +//! - The server depends on this crate only so that Cargo builds the +//! library. No Workshop crate links it in or calls it: Workshop reaches +//! the Plugin through the library alone. +//! - Names the Plugin SDK at the commit the pinned std Plugin was built +//! against. Cargo links one copy of the contract into a build, so a pin +//! that disagrees fails to resolve. + +runvil_plugin_sdk::export_plugin!(std_plugin::Std); diff --git a/crates/workshop/server-api/src/lib-tests.rs b/crates/workshop/server-api/src/lib-tests.rs index 9509656dd..93c03165b 100644 --- a/crates/workshop/server-api/src/lib-tests.rs +++ b/crates/workshop/server-api/src/lib-tests.rs @@ -14,6 +14,12 @@ fn spawn_signature(start: fn(Config) -> Result) { let _ = start; } +/// Compile-level check: `list_plugin_dir` resolves and has the server's +/// signature. +fn list_signature(list: fn(&std::path::Path) -> std::io::Result) { + let _ = list; +} + #[test] fn the_desktop_app_facing_surface_names_every_re_export() { // Configuration. @@ -32,6 +38,9 @@ fn the_desktop_app_facing_surface_names_every_re_export() { short_name::(), "GatewayPublicationError" ); + // The listing of a Plugin directory. + list_signature(list_plugin_dir); + assert_eq!(short_name::(), "PluginListing"); } #[test] diff --git a/crates/workshop/server-api/src/lib.rs b/crates/workshop/server-api/src/lib.rs index 5d8d4d2e8..cbb13cf3f 100644 --- a/crates/workshop/server-api/src/lib.rs +++ b/crates/workshop/server-api/src/lib.rs @@ -4,8 +4,9 @@ //! The desktop app (`workshop`) depends on this crate and never on //! `workshop-server`, so server internals do not resolve in the desktop app at //! all. The surface is the configuration types, the in-process server -//! lifecycle, and the Gateway publication seam; the `test-fixtures` -//! feature forwards the server's integration-test seams. +//! lifecycle, the Gateway publication seam, and the listing of a Plugin +//! directory; the `test-fixtures` feature forwards the server's +//! integration-test seams. //! //! ## Invariants //! @@ -17,8 +18,8 @@ //! item, and the desktop app's sole view of the server is this crate. pub use workshop_server::{ - AgentsConfig, Config, GatewayConfig, GatewayPublicationError, GatewayUpdater, ServerConfig, - ServerHandle, SpawnError, Termination, discovery, spawn, + AgentsConfig, Config, GatewayConfig, GatewayPublicationError, GatewayUpdater, PluginListing, + ServerConfig, ServerHandle, SpawnError, Termination, discovery, list_plugin_dir, spawn, }; /// The server's integration-test seams, forwarded to the desktop app's tests. diff --git a/crates/workshop/server/Cargo.toml b/crates/workshop/server/Cargo.toml index 1f59c23b0..87be393bf 100644 --- a/crates/workshop/server/Cargo.toml +++ b/crates/workshop/server/Cargo.toml @@ -28,8 +28,12 @@ plugin-web.workspace = true # The MCP client Pack, installed once over every server of the # configured `mcp.json`. plugin-mcp.workspace = true -# The file-tool Pack every agent run's Host installs: `runvil/std`. -plugin-std.workspace = true +# The std Plugin's dynamic library, which holds the file-tool Pack +# `runvil/std`. Nothing here links it in or calls it: the dependency makes +# Cargo build the library with every build of the server, into `deps` +# beside the test executables, and the server loads it from its Plugin +# directory like any other library Plugin. +workshop-plugin-std.workspace = true reqwest.workspace = true rust-embed.workspace = true serde.workspace = true diff --git a/crates/workshop/server/src/agents.rs b/crates/workshop/server/src/agents.rs index c8f379dd3..d7fa2d628 100644 --- a/crates/workshop/server/src/agents.rs +++ b/crates/workshop/server/src/agents.rs @@ -6,8 +6,10 @@ //! the two share only [`crate::websocket`]. //! //! The server builds one [`Host`] at startup: its `Packs`, installed from -//! the web, mcp, and std Plugins, mcp over the servers of the configured -//! `mcp.json`, and the built-ins subagents and user; a finder that reads +//! the linked web and mcp Plugins, mcp over the servers of the configured +//! `mcp.json`, the library Plugins in the configured Plugin directory, +//! the std Plugin with the file-tool Pack among them, and the built-ins +//! subagents and user; a finder that reads //! each agent from the agents folder through `load_agent`; and the tokio //! timer. Each launch opens a conversation in `workshop-agents` and builds //! that conversation's one run its own [`RunSetup`]: the conversation's @@ -51,9 +53,9 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use plugin_web::SEARCH_PROVIDER; -use runvil::harness::{Packs, RunId}; +use runvil::harness::{Packs, PluginReport, RunId, insert_plugin_dir}; use runvil::plugin::{HostServices, PackRegistry, ServiceKey}; -use runvil::{Error, ErrorCode, Host, RunSetup}; +use runvil::{Error, ErrorCode, ErrorFact, Host, RunSetup}; use serde_json::{Map, Value}; use workshop_agents::{ Conversation, ConversationId, Conversations, LaunchError, TokioTimer, discover_agents, @@ -78,11 +80,17 @@ const HARNESS_STATE_DIR: &str = "harness"; /// tokio runtime service, as the handle it supplies. const RUNTIME: ServiceKey = ServiceKey::new(runvil::plugin::TOKIO_RUNTIME); -/// The server's installed Packs over its Host-wide [`services`]. `mcp` is -/// the `[agents] mcp` setting: the mcp Pack is installed over the servers -/// of the file it names. -fn host_packs(registry: &Registry, mcp: Option<&Path>) -> Packs { - with_plugins(&services(registry), read_mcp_servers(mcp)) +/// The server's installed Packs over its Host-wide [`services`], and what +/// its Plugin directory loaded. `mcp` is the `[agents] mcp` setting: the +/// mcp Pack is installed over the servers of the file it names. `plugins` +/// is the `[agents] plugins` setting: the directory whose library Plugins +/// are loaded. +fn host_packs( + registry: &Registry, + mcp: Option<&Path>, + plugins: Option<&Path>, +) -> (Packs, Option) { + with_plugins(&services(registry), read_mcp_servers(mcp), plugins) } /// The `mcpServers` object of the `mcp.json`-format file at `setting`, @@ -140,12 +148,23 @@ fn read_mcp_servers(setting: Option<&Path>) -> Value { } } -/// A Host after the server calls, with the Host-wide `services`, the -/// Plugins of the Packs agents may declare: `web`, `mcp`, with the -/// `servers` object as its configuration, and `std`, the file tools. The -/// Host keeps those Packs and the built-ins `subagents` and `user`, and -/// each run makes its own instances of them. -fn with_plugins(services: &HostServices, servers: Value) -> Packs { +/// The Packs of the server's Host, and what its Plugin directory loaded. +/// +/// The linked Plugins, web and mcp, install first with the Host-wide +/// `services`, mcp with the `servers` object as its configuration. Then +/// every library Plugin in the directory `plugins` loads, when the server +/// is given one. The std Plugin, whose Pack holds the file tools, is one +/// of those libraries, so a Host whose Plugin directory lacks it has no +/// `std` Pack and refuses a prompt that requires it. A `tracing` warning +/// says so at startup. The registry also holds the built-ins `subagents` +/// and `user`, and each run makes its own instances of the Packs it +/// holds. The report lists each library Plugin that loaded and each +/// failure. +fn with_plugins( + services: &HostServices, + servers: Value, + plugins: Option<&Path>, +) -> (Packs, Option) { let mut packs = PackRegistry::new(); if let Err(error) = plugin_web::install(&mut packs, Value::Null, services) { tracing::warn!(%error, "the web Plugin inserted no Pack"); @@ -154,10 +173,101 @@ fn with_plugins(services: &HostServices, servers: Value) -> Packs { if let Err(error) = plugin_mcp::install(&mut packs, Value::Object(mcp), services) { tracing::warn!(%error, "the mcp Plugin inserted no Pack"); } - if let Err(error) = plugin_std::install(&mut packs, Value::Null, services) { - tracing::warn!(%error, "the std Plugin inserted no Pack"); + let report = plugins.and_then(|directory| load_plugin_dir(&mut packs, directory)); + if !holds_std(&packs) { + tracing::warn!( + directory = ?plugins, + "no library Plugin inserted the Pack runvil/std; an agent that requires std is refused" + ); + } + (Packs::new(packs), report) +} + +/// Whether `packs` holds the Pack of the file tools, `runvil/std`, which +/// only the std Plugin's library inserts. +fn holds_std(packs: &PackRegistry) -> bool { + packs.list().any(|(info, _)| info.fqn == "runvil/std") +} + +/// What the library Plugins in one directory do when they are loaded. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct PluginListing { + /// Each Plugin that loads, as its name, a space, and its version, in + /// the order the directory loads them. + pub loaded: Vec, + /// Each library that does not load, as its path and the reason. + pub failed: Vec, +} + +/// Loads every library Plugin in `directory`, lists what each did, and +/// unloads them all again. +/// +/// The desktop app prints the listing for `runvil-workshop --plugins`. +/// Workshop keeps no log, so this is how a person, or an installer test, +/// sees which Plugins an installation loads. +/// +/// # Errors +/// Returns the I/O error when `directory` cannot be read, as when it does +/// not exist. +pub fn list_plugin_dir(directory: &Path) -> io::Result { + let mut packs = PackRegistry::new(); + let report = insert_plugin_dir(&mut packs, directory)?; + let loaded = report + .plugins() + .iter() + .map(|plugin| format!("{} {}", plugin.name(), plugin.version())) + .collect(); + let failed = report + .failures() + .iter() + .map(|failure| { + let path = failure + .facts + .get(&ErrorFact::Path) + .and_then(Value::as_str) + .unwrap_or_default(); + format!("{path}: {failure}") + }) + .collect(); + Ok(PluginListing { loaded, failed }) +} + +/// Loads every library Plugin in `directory` and returns the report of +/// what loaded, or `None` when the directory cannot be read. A directory +/// that does not exist is a state, not a failure: a build that has not +/// placed its Plugins has none. Each Plugin that loaded is logged with +/// its name, version, and source, and each library that did not, with its +/// path and the reason; one failure never stops the others. +fn load_plugin_dir(packs: &mut PackRegistry, directory: &Path) -> Option { + let report = match insert_plugin_dir(packs, directory) { + Ok(report) => report, + Err(error) if error.kind() == io::ErrorKind::NotFound => return None, + Err(error) => { + tracing::warn!( + directory = %directory.display(), + %error, + "the Plugin directory could not be read; no library Plugin is loaded" + ); + return None; + } + }; + for plugin in report.plugins() { + tracing::info!( + name = plugin.name(), + version = plugin.version(), + source = ?plugin.source(), + "loaded a library Plugin" + ); + } + for failure in report.failures() { + let path = failure + .facts + .get(&ErrorFact::Path) + .and_then(Value::as_str) + .unwrap_or_default(); + tracing::warn!(path, error = %failure, "a library Plugin did not load"); } - Packs::new(packs) + Some(report) } /// The Host-wide services the Plugins' `install`s read: the search @@ -233,6 +343,10 @@ struct Inner { /// instances of, the finder that reads each agent from the agents /// folder, and the tokio timer. host: Arc, + /// What the Plugin directory loaded, held so that a library Plugin + /// which inserted no Pack still stays loaded for as long as the + /// launcher lives. + _plugins: Option, /// The running conversations. conversations: Conversations, } @@ -251,12 +365,18 @@ impl AgentSessions { /// the server's reconnect backoff. The only file this reads is the /// `[agents] mcp` file, when the setting names one, and the only /// connections it starts are the background ones to that file's MCP - /// servers, on the server's runtime. Nothing else is spawned and - /// nothing else touches the filesystem here: the run log opens under - /// the state directory when the first run starts. + /// servers, on the server's runtime. It opens each dynamic library in + /// the `[agents] plugins` directory, when the setting names one, and + /// loads its Plugin, which runs that library's code. Nothing else is + /// spawned and nothing else touches the filesystem here: the run log + /// opens under the state directory when the first run starts. #[must_use] pub fn new(config: &Config, registry: Registry, backoff: ReconnectBackoff) -> Self { - let packs = host_packs(®istry, config.agents.mcp.as_deref()); + let (packs, plugins) = host_packs( + ®istry, + config.agents.mcp.as_deref(), + config.agents.plugins.as_deref(), + ); let agents_path = config.agents.path.clone(); let host = Host { packs, @@ -271,6 +391,7 @@ impl AgentSessions { )), broker: WorkshopBroker::new(registry.clone()), host: Arc::new(host), + _plugins: plugins, conversations: Conversations::new(), registry, backoff, diff --git a/crates/workshop/server/src/agents/language_guide/tests.rs b/crates/workshop/server/src/agents/language_guide/tests.rs index 2d540c2ba..0db37a1e0 100644 --- a/crates/workshop/server/src/agents/language_guide/tests.rs +++ b/crates/workshop/server/src/agents/language_guide/tests.rs @@ -13,7 +13,7 @@ use workshop_registry::Registry; use super::mount_at; use crate::agents::host_packs; -use crate::agents::tests::run_prompt_over; +use crate::agents::tests::{plugin_dir, run_prompt_over}; /// What a test returns: any error from setup or from the VFS fails it. type Outcome = Result<(), Box>; @@ -149,7 +149,9 @@ return listing .. '\n' .. page #[tokio::test] async fn a_prompt_sees_the_directory_mapped_under_the_runvil_mount() -> Outcome { let directory = local_directory()?; - let host = host_packs(&Registry::new(), None); + // The std Plugin is a library, which the Host loads from a directory. + let (plugins, _library) = plugin_dir("sees-the-mapping"); + let (host, _) = host_packs(&Registry::new(), None, Some(plugins.path())); let result = run_prompt_over(host, PROMPT.to_owned(), filesystem_with(directory.path())).await; diff --git a/crates/workshop/server/src/agents/socket-tests.rs b/crates/workshop/server/src/agents/socket-tests.rs index 1b5d4593d..22b9fdc7b 100644 --- a/crates/workshop/server/src/agents/socket-tests.rs +++ b/crates/workshop/server/src/agents/socket-tests.rs @@ -55,7 +55,7 @@ async fn a_launch_hands_back_the_parse_failure_its_run_ended_on_before_any_attac let config = Config { agents: AgentsConfig { path: agents_dir, - mcp: None, + ..AgentsConfig::default() }, ..config_for("http://127.0.0.1:1", dir.path()) }; diff --git a/crates/workshop/server/src/agents/tests.rs b/crates/workshop/server/src/agents/tests.rs index e5ccddf97..d6a0610a4 100644 --- a/crates/workshop/server/src/agents/tests.rs +++ b/crates/workshop/server/src/agents/tests.rs @@ -1,5 +1,6 @@ //! The agent conversations' Harness setup: after the server calls the -//! three Plugins and builds its Host, every run of a conversation, which +//! two linked Plugins, loads the std Plugin's library from its Plugin +//! directory, and builds its Host, every run of a conversation, which //! has a user, holds the `web`, `subagents`, `mcp`, `std`, and `user` //! Packs. A prompt declaring `web` or the built-in //! `subagents` prepares over the server's installed Packs, a Host without @@ -11,26 +12,36 @@ //! servers value is the mcp Pack's configuration, so one it refuses //! refuses a prompt requiring `mcp` as it parses. The Host's finder //! refuses an unknown agent as `NotFound` and an unreadable one as `Io`, -//! each with the launch refusal's own text. +//! each with the launch refusal's own text. A configured Plugin directory +//! loads each library Plugin in it and reports each file that is none, +//! and a directory that does not exist loads nothing. A prompt reads a +//! file of its run through the std library's `Read` tool, and a Host with +//! no Plugin directory refuses a prompt that requires `std`. +use std::env::consts::{DLL_EXTENSION, DLL_PREFIX, DLL_SUFFIX}; +use std::path::PathBuf; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use plugin_web::SEARCH_PROVIDER; use runvil::effect::Round; -use runvil::harness::{InferenceBroker, MemoryRecorder, Packs}; +use runvil::harness::{InferenceBroker, MemoryRecorder, Packs, PluginSource}; use runvil::model::{ Completion, CompletionOptions, Message, ModelBinding, ModelCatalog, ToolSchema, make_completion_error, phrase, }; -use runvil::plugin::{HostServices, ServiceKey, TOKIO_RUNTIME}; -use runvil::vfs::VfsRef; -use runvil::{BoxFuture, Error, ErrorCode, Host, RunSetup}; +use runvil::plugin::{HostServices, PackRegistry, ServiceKey, TOKIO_RUNTIME}; +use runvil::vfs::{Origin, VfsRef}; +use runvil::{BoxFuture, Error, ErrorCode, ErrorFact, Host, RunSetup}; use serde_json::json; use workshop_agents::{Conversations, SessionState, TokioTimer, load_agent}; use workshop_registry::Registry; -use super::{agent_finder, host_packs, read_mcp_servers, services, with_plugins}; +use super::{ + PluginListing, agent_finder, holds_std, host_packs, list_plugin_dir, load_plugin_dir, + read_mcp_servers, services, with_plugins, +}; /// The runtime's key, built from the Plugin contract's name as any Host /// builds it. @@ -136,7 +147,8 @@ async fn every_run_holds_the_packs_the_three_plugins_add_and_the_builtins() { names[#names + 1] = tools.packs.get(id).name\n\ end\n\ return table.concat(names, ',') .. '|' .. tostring(tools.packs.get('user-input'))\n```\n"; - let host = host_packs(&Registry::new(), None); + let (dir, _library) = plugin_dir("every-run-holds"); + let (host, _) = host_packs(&Registry::new(), None, Some(dir.path())); assert_eq!( run_prompt(host, lists.to_owned()).await, Ok(Ok("web,subagents,mcp,std,user|nil".to_owned())), @@ -147,7 +159,7 @@ async fn every_run_holds_the_packs_the_three_plugins_add_and_the_builtins() { #[tokio::test] async fn a_prompt_declaring_web_prepares_on_the_servers_built_packs() { - let host = host_packs(&Registry::new(), None); + let (host, _) = host_packs(&Registry::new(), None, None); assert_eq!( run_prompt(host, requiring("web")).await, Ok(Ok("browsed".to_owned())), @@ -157,7 +169,7 @@ async fn a_prompt_declaring_web_prepares_on_the_servers_built_packs() { #[tokio::test] async fn a_prompt_declaring_subagents_prepares_on_the_servers_built_packs() { - let host = host_packs(&Registry::new(), None); + let (host, _) = host_packs(&Registry::new(), None, None); assert_eq!( run_prompt(host, requiring("subagents")).await, Ok(Ok("browsed".to_owned())), @@ -171,7 +183,7 @@ async fn a_host_without_the_search_provider_refuses_a_prompt_requiring_web() { runtime_only .provide(&RUNTIME, Arc::new(tokio::runtime::Handle::current())) .expect("an empty map takes the runtime"); - let host = with_plugins(&runtime_only, json!({})); + let (host, _) = with_plugins(&runtime_only, json!({}), None); let result = run_prompt(host, requiring("web")).await; let Err(message) = result else { @@ -217,7 +229,7 @@ async fn a_prompt_declaring_mcp_prepares_on_the_runtime_the_server_provides() { // The server's own `services` provides the runtime, and `plugin-mcp` // reads it through a key of its own even with no servers, so only a // run over both shows the two keys name the same service. - let host = host_packs(&Registry::new(), None); + let (host, _) = host_packs(&Registry::new(), None, None); assert_eq!( run_prompt(host, requiring("mcp")).await, Ok(Ok("browsed".to_owned())), @@ -229,7 +241,7 @@ async fn a_prompt_declaring_mcp_prepares_on_the_runtime_the_server_provides() { async fn the_servers_value_is_the_mcp_packs_configuration() { // A value that is neither null nor an object is the one configuration // the install refuses, so only that value reaching it fails the run. - let host = with_plugins(&services(&Registry::new()), json!([])); + let (host, _) = with_plugins(&services(&Registry::new()), json!([]), None); let result = run_prompt(host, requiring("mcp")).await; let Err(message) = result else { @@ -241,6 +253,208 @@ async fn the_servers_value_is_the_mcp_packs_configuration() { ); } +/// Counts the Plugin directories this process has made, so two tests +/// never share one. +static PLUGIN_DIRS: AtomicUsize = AtomicUsize::new(0); + +/// The file name of the std Plugin's dynamic library. +fn std_plugin_file() -> String { + format!("{DLL_PREFIX}plugin_std{DLL_SUFFIX}") +} + +/// The std Plugin's dynamic library. Cargo builds it beside this test +/// executable, because the crate depends on `workshop-plugin-std`. +fn std_plugin_library() -> PathBuf { + let exe = std::env::current_exe().expect("the test executable has a path"); + let dir = exe.parent().expect("the test executable has a directory"); + dir.join(std_plugin_file()) +} + +/// A Plugin directory for the test named `test`, holding a copy of the +/// std Plugin's library, and that copy's path. The directory's name comes +/// from the test, the process id, and a counter. +pub(super) fn plugin_dir(test: &str) -> (tempfile::TempDir, PathBuf) { + let prefix = format!( + "{test}-{}-{}-", + std::process::id(), + PLUGIN_DIRS.fetch_add(1, Ordering::Relaxed) + ); + let dir = tempfile::Builder::new() + .prefix(&prefix) + .tempdir() + .expect("a temporary directory is made"); + let library = dir.path().join(std_plugin_file()); + std::fs::copy(std_plugin_library(), &library).expect("the std Plugin's library is copied"); + (dir, library) +} + +#[test] +fn a_plugin_directory_loads_the_std_plugin_from_its_library() { + let (dir, library) = plugin_dir("loads-std"); + let mut packs = PackRegistry::new(); + let report = load_plugin_dir(&mut packs, dir.path()).expect("the directory is read"); + + assert!(report.failures().is_empty(), "{:?}", report.failures()); + let [plugin] = report.plugins() else { + panic!("one library loads one Plugin: {:?}", report.plugins()); + }; + assert_eq!(plugin.name(), "std"); + assert_eq!(plugin.version(), "0.1.0"); + assert_eq!( + plugin.source(), + &PluginSource::Library(std::path::absolute(&library).expect("the path is made absolute")) + ); +} + +#[test] +fn a_file_that_is_no_library_is_reported_and_the_std_plugin_still_loads() { + let (dir, _library) = plugin_dir("reports-a-bad-file"); + let bad = dir.path().join(format!("a-text-file.{DLL_EXTENSION}")); + std::fs::write(&bad, "not a library").expect("write fixture"); + let mut packs = PackRegistry::new(); + let report = load_plugin_dir(&mut packs, dir.path()).expect("the directory is read"); + + assert_eq!(report.plugins().len(), 1, "{:?}", report.plugins()); + let [failure] = report.failures() else { + panic!("the text file is the one failure: {:?}", report.failures()); + }; + assert_eq!(failure.code, ErrorCode::PluginLoadFailed); + assert_eq!(failure.facts.get(&ErrorFact::Reason), Some(&json!("open"))); + let expected = std::path::absolute(&bad).expect("the path is made absolute"); + assert_eq!( + failure.facts.get(&ErrorFact::Path), + Some(&json!(expected.display().to_string())) + ); +} + +#[test] +fn a_listing_names_each_plugin_that_loads_and_each_library_that_does_not() { + let (dir, _library) = plugin_dir("lists-std"); + assert_eq!( + list_plugin_dir(dir.path()).expect("the directory is read"), + PluginListing { + loaded: vec!["std 0.1.0".to_owned()], + failed: Vec::new(), + } + ); + + let bad = dir.path().join(format!("a-text-file.{DLL_EXTENSION}")); + std::fs::write(&bad, "not a library").expect("write fixture"); + let listing = list_plugin_dir(dir.path()).expect("the directory is read"); + assert_eq!(listing.loaded, ["std 0.1.0"]); + let [failed] = listing.failed.as_slice() else { + panic!("the text file is the one failure: {:?}", listing.failed); + }; + let path = std::path::absolute(&bad).expect("the path is made absolute"); + assert!( + failed.starts_with(&format!("{}: ", path.display())), + "the line opens with the library's path: {failed}" + ); + + let missing = list_plugin_dir(&dir.path().join("absent")).expect_err("no such directory"); + assert_eq!(missing.kind(), std::io::ErrorKind::NotFound); +} + +#[test] +fn a_plugin_directory_that_does_not_exist_loads_nothing() { + let (dir, _library) = plugin_dir("missing-directory"); + let mut packs = PackRegistry::new(); + assert!( + load_plugin_dir(&mut packs, &dir.path().join("absent")).is_none(), + "a missing directory is a state, not a failure" + ); +} + +#[tokio::test] +async fn the_servers_host_loads_its_plugin_directory_beside_the_linked_plugins() { + let (dir, _library) = plugin_dir("server-host"); + let (host, report) = host_packs(&Registry::new(), None, Some(dir.path())); + + let report = report.expect("the configured directory is read"); + let names: Vec<&str> = report + .plugins() + .iter() + .map(|plugin| plugin.name()) + .collect(); + assert_eq!(names, ["std"]); + assert_eq!( + run_prompt(host, requiring("std")).await, + Ok(Ok("browsed".to_owned())), + "the library's Pack is installed, so a prompt that requires std runs" + ); +} + +#[test] +fn only_a_plugin_directory_with_the_std_library_gives_the_host_the_std_pack() { + let (dir, _library) = plugin_dir("holds-std"); + let mut packs = PackRegistry::new(); + load_plugin_dir(&mut packs, dir.path()).expect("the directory is read"); + assert!(holds_std(&packs)); + assert!( + !holds_std(&PackRegistry::new()), + "std is not among the built-ins" + ); +} + +/// A prompt that declares `std` and returns what `std/read` answers for +/// `/notes.md`, then what `std/write` answers. +const READS_NOTES: &str = "---\nname: reads\ndescription: reads a file\nrunvil: 0\n\ + uses:\n - std\n---\n\n# Reads\n\n## Only\n\n```lua\n\ + local read = tools.call('std/read', { path = '/notes.md' })\n\ + local write = tools.call('std/write', { path = '/out.md', contents = 'x' })\n\ + return read .. '|' .. write\n```\n"; + +#[tokio::test] +async fn a_prompt_reads_a_file_of_its_run_through_the_std_library_plugin() { + let (dir, _library) = plugin_dir("reads-notes"); + let (host, report) = host_packs(&Registry::new(), None, Some(dir.path())); + let report = report.expect("the configured directory is read"); + assert!(report.failures().is_empty(), "{:?}", report.failures()); + + let vfs = VfsRef::default(); + vfs.acquire(Origin::new("seed")) + .expect("a fresh memory filesystem acquires") + .write("/notes.md", b"hello\nworld\n") + .expect("the file is seeded"); + + let result = run_prompt_over(host, READS_NOTES.to_owned(), vfs).await; + let Ok(Ok(text)) = result else { + panic!("the run ends with its text: {result:?}"); + }; + // Each numbered line holds a `|`, and the stub's text holds none. + let (read, write) = text.rsplit_once('|').expect("the prompt joins two answers"); + assert!( + read.contains(" 1|hello\n 2|world\n"), + "Read answers the file's numbered lines: {read}" + ); + assert!( + read.contains(" PathBuf { + /// The directory, made once for each test process. + static DIRECTORY: OnceLock = OnceLock::new(); + DIRECTORY.get_or_init(make_std_plugin_dir).clone() +} + +/// Makes the directory [`std_plugin_dir`] returns, with the link in it. +fn make_std_plugin_dir() -> PathBuf { + let file = format!("{DLL_PREFIX}plugin_std{DLL_SUFFIX}"); + let exe = std::env::current_exe().expect("the test executable has a path"); + let beside = exe.parent().expect("the test executable has a directory"); + let built = beside.join(&file); + let directory = beside.join("workshop-server-plugins"); + std::fs::create_dir_all(&directory).expect("the Plugin directory is made"); + let link = directory.join(&file); + if !is_link_to(&link, &built) { + // A link an earlier build left names that build's library. Each + // test is its own process, and another may read the directory at + // any moment. So the new link is made under a name of this + // process, which the loader skips for its extension, and renamed + // over the old one: the directory is never without the library. + let staged = directory.join(format!("{file}.{}.tmp", std::process::id())); + let _ = std::fs::remove_file(&staged); + let placed = + std::fs::hard_link(&built, &staged).and_then(|()| std::fs::rename(&staged, &link)); + if let Err(error) = placed { + let _ = std::fs::remove_file(&staged); + // Another process may have placed the link first, and Windows + // does not replace a library that a process has loaded. + assert!( + is_link_to(&link, &built), + "cannot link {} to {}: {error}", + link.display(), + built.display() + ); + } + } + directory +} + +/// Whether `link` and `built` have one length and one modification time, +/// as two names of one file do. +fn is_link_to(link: &Path, built: &Path) -> bool { + let stamp = |path: &Path| -> Option<(u64, SystemTime)> { + let metadata = std::fs::metadata(path).ok()?; + Some((metadata.len(), metadata.modified().ok()?)) + }; + stamp(link).is_some_and(|linked| Some(linked) == stamp(built)) +} + /// A workshop server spawned in-process for one test, anchoring its state /// in a tempdir that lives as long as the fixture. /// diff --git a/crates/workshop/server/tests/it/agents.rs b/crates/workshop/server/tests/it/agents.rs index fcc580606..d91bf921b 100644 --- a/crates/workshop/server/tests/it/agents.rs +++ b/crates/workshop/server/tests/it/agents.rs @@ -24,8 +24,8 @@ use workshop_server::fixtures::{gateway_updater, replace_gateway as replace_fixt use workshop_server::{AgentsConfig, AppState, Config}; use crate::common::{ - JsonSocket, connect_agents, echo_stream, launch, spawn_gateway, spawn_router, test_config, - typed_catalog, + JsonSocket, connect_agents, echo_stream, launch, spawn_gateway, spawn_router, std_plugin_dir, + test_config, typed_catalog, }; /// The echo agent: a Markdown prompt on the unified runtime that loops @@ -106,7 +106,8 @@ async fn spawn_agent_server_for_gateway(base_url: String) -> (String, tempfile:: let config = Config { agents: AgentsConfig { path: agents_dir, - mcp: None, + plugins: Some(std_plugin_dir()), + ..AgentsConfig::default() }, ..test_config(&base_url, dir.path()) }; diff --git a/crates/workshop/server/tests/it/chat_gate.rs b/crates/workshop/server/tests/it/chat_gate.rs index 5d0f75453..abc887a62 100644 --- a/crates/workshop/server/tests/it/chat_gate.rs +++ b/crates/workshop/server/tests/it/chat_gate.rs @@ -33,7 +33,7 @@ use workshop_server::{AgentsConfig, AppState, Config}; use crate::agents::{answer, collect_turn, delta_text, is_awaiting, next_wait}; use crate::common::{ JsonSocket, connect_agents, echo_stream, launch, spawn_gateway, spawn_router, sse_chunk, - sse_line, test_config, typed_catalog, + sse_line, std_plugin_dir, test_config, typed_catalog, }; /// Every completion request body the gate mock received, in arrival @@ -129,7 +129,8 @@ async fn spawn_chat_server_with_selection(models: &[&str], selected: Option<&str let config = Config { agents: AgentsConfig { path: dir.path().join("missing-agents"), - mcp: None, + plugins: Some(std_plugin_dir()), + ..AgentsConfig::default() }, ..test_config(&gateway_url, dir.path()) }; diff --git a/crates/workshop/server/tests/it/chat_gate/compaction.rs b/crates/workshop/server/tests/it/chat_gate/compaction.rs index 287d8d419..9bb06d34e 100644 --- a/crates/workshop/server/tests/it/chat_gate/compaction.rs +++ b/crates/workshop/server/tests/it/chat_gate/compaction.rs @@ -96,7 +96,8 @@ async fn spawn_compacting_server() -> GateServer { let config = Config { agents: AgentsConfig { path: dir.path().join("missing-agents"), - mcp: None, + plugins: Some(std_plugin_dir()), + ..AgentsConfig::default() }, ..test_config(&gateway_url, dir.path()) }; diff --git a/crates/workshop/server/tests/it/std_real_directory.rs b/crates/workshop/server/tests/it/std_real_directory.rs index 3fa98937e..cf71586e9 100644 --- a/crates/workshop/server/tests/it/std_real_directory.rs +++ b/crates/workshop/server/tests/it/std_real_directory.rs @@ -3,20 +3,26 @@ //! the directory, find a word with Grep, read a window of the file, and read //! the next window from the offset the first window's trailer names. //! -//! This lives here and not in `plugin-std` because the crate needs the -//! real backend, and a Plugin crate imports only `runvil-plugin`. +//! The tools come from the std Plugin's dynamic library, loaded from a +//! Plugin directory as the server loads it, so each call crosses the C +//! contract and each read goes through the loader's file functions to the +//! real backend. use std::future::Future; use std::pin::pin; +use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::task::{Context, Poll, Waker}; use runvil::effect::{ToolCallOrigin, ToolCaller}; -use runvil::plugin::{HostServices, Pack, PackRegistry, ToolContext}; +use runvil::harness::{PluginReport, insert_plugin_dir}; +use runvil::plugin::{Pack, PackRegistry, ToolContext}; use runvil::vfs::{Access, Origin, RealBackend, VfsRef}; use runvil::{OutputTrust, ToolId, ToolOutput}; use serde_json::{Value, json}; +use crate::common::std_plugin_dir; + /// The file the flow runs over. const FILE: &str = "big.txt"; @@ -70,24 +76,26 @@ fn seeded_directory() -> tempfile::TempDir { directory } -/// The Pack the factory `plugin_std::install` inserts makes under the Pack -/// id `std`. +/// One run's instance of the Pack `runvil/std`, made by the std Plugin's +/// library, loaded from the Plugin directory the tests share. The report +/// keeps the Plugin loaded, so it comes back with the Pack. #[expect( clippy::expect_used, reason = "test helpers fail by panicking with the invariant named" )] -fn std_pack() -> std::sync::Arc { +fn std_pack() -> (Arc, PluginReport) { let mut packs = PackRegistry::new(); - plugin_std::install(&mut packs, Value::Null, &HostServices::new()) - .expect("a fresh registry takes the Pack"); + let report = insert_plugin_dir(&mut packs, &std_plugin_dir()).expect("the directory is read"); + assert!(report.failures().is_empty(), "{:?}", report.failures()); let (_info, id, factory) = packs .into_entries() .find(|(info, _, _)| info.fqn == "runvil/std") - .expect("install inserts the std Pack"); - factory - .expect("install inserts std usable with no configuration") + .expect("the std Plugin inserts its Pack"); + let pack = factory + .expect("the Pack is usable") .new_pack(&id) - .expect("the Pack is made") + .expect("the Pack instance is made"); + (pack, report) } /// Drives a future to its output, polling again after each `Pending`. @@ -121,7 +129,7 @@ fn list_find_read_a_window_and_read_the_next_one_by_the_trailer_offset() { let access = vfs .acquire(Origin::new("std real directory")) .expect("the run acquires the filesystem"); - let pack = std_pack(); + let (pack, _report) = std_pack(); let listing = call(&*pack, &access, "dir", json!({ "target_directory": "/" })); assert_eq!(listing.trust(), OutputTrust::Untrusted); diff --git a/crates/workshop/support/src/config.rs b/crates/workshop/support/src/config.rs index 0d517b316..8edc4870a 100644 --- a/crates/workshop/support/src/config.rs +++ b/crates/workshop/support/src/config.rs @@ -177,6 +177,12 @@ pub struct AgentsConfig { /// verbatim like other explicit paths, so `anchor_path_defaults` /// leaves it alone. pub mcp: Option, + /// The directory whose dynamic libraries Workshop loads as Plugins. + /// Leaving it out loads no directory, and a directory that does not + /// exist loads no Plugin. The path is interpolated and kept verbatim + /// as `mcp` is. The desktop app fills a setting left out with the + /// `plugins` directory of its own installation. + pub plugins: Option, } impl Default for AgentsConfig { @@ -184,6 +190,7 @@ impl Default for AgentsConfig { Self { path: PathBuf::new(), mcp: None, + plugins: None, } } } diff --git a/crates/workshop/support/tests/it/config.rs b/crates/workshop/support/tests/it/config.rs index 0c4f94bae..5bbb539ef 100644 --- a/crates/workshop/support/tests/it/config.rs +++ b/crates/workshop/support/tests/it/config.rs @@ -257,3 +257,30 @@ fn an_absent_agents_mcp_setting_is_none() { let config = Config::from_toml_str(raw).expect("fixture parses"); assert_eq!(config.agents.mcp, None, "no setting means no MCP servers"); } + +#[test] +fn the_agents_plugins_setting_parses_to_its_path_and_is_kept_verbatim() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("workshop.toml"); + std::fs::write( + &path, + "[gateway]\nbase_url = \"http://x\"\napi_key = \"k\"\n\n[agents]\nplugins = \"my-plugins\"\n", + ) + .expect("write fixture"); + let config = Config::load(&path).expect("fixture loads"); + assert_eq!( + config.agents.plugins, + Some(PathBuf::from("my-plugins")), + "an explicit Plugin directory is not re-anchored beside the config file" + ); +} + +#[test] +fn an_absent_agents_plugins_setting_is_none() { + let raw = "[gateway]\nbase_url = \"http://x\"\napi_key = \"k\"\n"; + let config = Config::from_toml_str(raw).expect("fixture parses"); + assert_eq!( + config.agents.plugins, None, + "no setting means no Plugin directory" + ); +} diff --git a/crates/workshop/ui/test/docs-claims.mjs b/crates/workshop/ui/test/docs-claims.mjs index 06f544680..a43b32a43 100644 --- a/crates/workshop/ui/test/docs-claims.mjs +++ b/crates/workshop/ui/test/docs-claims.mjs @@ -162,7 +162,7 @@ const PLUGIN_DOC_DIRS = [ "crates/harness-gateway-client", "crates/plugin-web", "crates/plugin-mcp", - "crates/plugin-std", + "crates/workshop/plugin-std", ]; test("the root AGENTS.md and the Pack-facing crates' comments never describe Pack activation", async () => { diff --git a/deny.toml b/deny.toml index d08b89338..75435ba50 100644 --- a/deny.toml +++ b/deny.toml @@ -67,11 +67,13 @@ crate = "serde_json" deny = ["preserve_order"] [sources] -# Only crates.io is an allowed registry. The one git source is the C Plugin -# contract's own repository, which Cargo.toml pins by commit. +# Only crates.io is an allowed registry. The two git sources are the C +# Plugin contract's own repository and the std Plugin's, each of which +# Cargo.toml pins by commit. unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] allow-git = [ "https://github.com/cppalliance/runvil-plugin-api", + "https://github.com/cppalliance/runvil-plugin-std", ]