diff --git a/.github/workflows/advisories.yml b/.github/workflows/advisories.yml new file mode 100644 index 0000000..d3ace91 --- /dev/null +++ b/.github/workflows/advisories.yml @@ -0,0 +1,26 @@ +name: advisories + +# RustSec advisories against Cargo.lock, weekly and by hand; never part of `pr`, so a new advisory blocks no merge. +# A red scheduled run emails the account that last changed the cron line. + +on: + schedule: + - cron: "17 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + advisories: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: cargo deny + uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 + with: + command: check advisories diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 69a9f9e..a6c0e86 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -17,6 +17,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Gate suite run: sh scripts/pr-check.test.sh diff --git a/SECURITY.md b/SECURITY.md index 9389e13..eedc2dd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -36,5 +36,5 @@ Out of scope: - **Panes are outside it.** A helper started before the sandbox launches every pane. A pane left at Everything and Open keeps your full access to files and the network; its isolation (**Isolation…**) narrows that with a Seatbelt profile of its own, a proxy for Only these providers, or another account. An agent's own Seatbelt sandbox cannot run inside an isolated pane. - **`open` is outside it.** Opening a file or link "the way the system would", and opening a new terminal window, go through LaunchServices, which starts those programs outside the sandbox. - **A sandbox that fails to start does not stop the server.** The failure is written to `daemon.log`, shown by `nerd --list`, and shown in the window of every client that attaches. -- **A release is signed, and what that covers depends on the way in.** Its `.sha256` is signed with nerd's release key, kept off GitHub; install.sh carries the public halves of that key and its backup, and installs nothing whose signature does not check. An update from nerd's menu uses the install.sh built into the running nerd, so someone who can replace a release on GitHub cannot make it install theirs. `curl … | sh` downloads install.sh itself from nerdterm.dev, so it is only as safe as nerdterm/nerd's release tags and the site; it refuses a release older than the nerd already installed, but a first install takes whichever signed release GitHub calls the latest, older ones included. Homebrew pins the archive's sha256, written into the formula only after the release script checked the signature, so it is as safe as nerdterm/homebrew-tap. It cannot stop someone holding the key, a release held back or with its signature removed (nerd then reports the release has no signature yet and stays where it is), or an older nerd that never updates; the changelog shown before installing is not signed. Releases before 0.15.0 were not signed, and a nerd before 0.15.0 installs the next release without checking its signature; every update after that is checked. The installer refuses an unsigned archive given by hand unless `NERD_INSTALL_UNVERIFIED=1` is set. +- **A release is signed, and what that covers depends on the way in.** Its `.sha256` is signed with nerd's release key, kept off GitHub; install.sh carries the public halves of that key and its backup, and installs nothing whose signature does not check. An update from nerd's menu uses the install.sh built into the running nerd, so someone who can replace a release on GitHub cannot make it install theirs. `curl … | sh` downloads install.sh itself from nerdterm.dev, so it is only as safe as nerdterm/nerd's release tags and the site; it refuses a release older than the nerd already in its install folder (by default ~/.local/bin), but a first install takes whichever signed release GitHub calls the latest, older ones included. Homebrew pins the archive's sha256, written into the formula only after the release script checked the signature, so it is as safe as nerdterm/homebrew-tap. It cannot stop someone holding the key, a release held back or with its signature removed (nerd then reports the release has no signature yet and stays where it is), or an older nerd that never updates; the changelog shown before installing is not signed. Releases before 0.15.0 were not signed, and a nerd before 0.15.0 installs the next release without checking its signature; every update after that is checked. The installer refuses an unsigned archive given by hand unless `NERD_INSTALL_UNVERIFIED=1` is set, and a signed archive whose nerd does not report the version in the archive's name. The release key signs only the release commit on master: the release script refuses to build and sign any other commit, or a binary reporting another version. Building and testing run every dependency's build code as the maintainer, so the script reads its signing script, formula script and install.sh from the release commit in git before it builds and runs that text afterwards, and after the build runs only the system's own commands and the nerd it built until the key has been used: a build that rewrites those files or puts commands of its own on the maintainer's PATH cannot run them in their place. It cannot stop build code that keeps running as the maintainer from reading the terminal while the key's passphrase is typed, using the maintainer's GitHub login, changing the binary being built, or waiting for a later release; only building releases as a separate macOS user would. - **Both ends check the other's user on every connection.** The server talks only to processes of its own user, and a client refuses a server socket another user created first, for example at a `NERD_SOCKET` in a shared directory, before it sends anything. Such a socket still keeps nerd from starting there: keep `NERD_SOCKET` in a directory only you can write. diff --git a/deny.toml b/deny.toml index 20b7d27..34b2ad0 100644 --- a/deny.toml +++ b/deny.toml @@ -46,7 +46,8 @@ deny = [ { name = "tonic" }, { name = "want" }, # mio is not banned: crossterm/signal-hook-mio use it only for stdin/tty readiness - # (SourceFd), never sockets; enforced via cackle.toml withholding its "net" api. + # (SourceFd), never sockets. cackle.toml records the withheld "net" api, but only scripts/audit.sh + # runs cackle (on Linux, by hand); what holds is the server's Seatbelt profile and tests/egress.rs. ] [sources] @@ -55,5 +56,5 @@ unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] [advisories] -# Unlike licenses/bans/sources, this check downloads the RustSec database over the -# network on every run: run it deliberately, never from CI or a script by default. +# Unlike licenses/bans/sources, this check downloads the RustSec database over the network: +# .github/workflows/advisories.yml runs it weekly and by hand, never in `pr` or gates.sh. diff --git a/docs/isolation.md b/docs/isolation.md index 4a49105..f1f59d6 100644 --- a/docs/isolation.md +++ b/docs/isolation.md @@ -21,7 +21,7 @@ The hosts in the agents' `plugin.json` are a starting point: check them against A pane's `~/.pi/agent/*.lock` is writable from every This-folder-only pane: one session can hold another pi session's lock, which only delays that session until the lock is deleted. -**This folder only** also keeps one session from planting code or instructions for another. Inside the places it may write, these stay read-only: shell, git and editor startup files, `~/.local/bin` and other folders ahead on `PATH`, agents' settings and memory, Claude Code's task list, Codex's shared databases, Qwen Code's workflow scripts, what agents source or run later from their own folders, and the caches package managers run code from (pnpm, uv, deno, Go, Playwright, Poetry, Yarn, node-gyp, pip, Cypress, SwiftPM, Copilot). When the pane's folder is your home folder, its dot files, `~/Library` and `~/bin` stay read-only too, except `~/Library/Caches` and the agents' session folders; choose a project folder for a real limit. The **Isolation** window lists what stops working. +**This folder only** also keeps one session from planting code or instructions for another. Inside the places it may write, these stay read-only: shell, git and editor startup files, `~/.local/bin` and other folders ahead on `PATH`, agents' settings and memory, Claude Code's task list, Codex's shared databases, Qwen Code's workflow scripts, what agents source or run later from their own folders, and the known caches whose contents run later (pnpm, uv, deno, Go, Playwright, Poetry, Yarn, node-gyp, pip, Cypress, SwiftPM, Copilot, the system Python's bytecode, Homebrew, ccache, sccache, esbuild, Bazelisk, Coursier); another cache under `~/Library/Caches` stays writable. Seatbelt sees a folder moved in whole as one being made, so a missing `~/./projects` folder, or a project folder in it an agent has not made yet, can still be put in place with memory or Qwen Code's workflow scripts inside. When the pane's folder is your home folder, its dot files, `~/Library` and `~/bin` stay read-only too, except `~/Library/Caches` and the agents' session folders; choose a project folder for a real limit. The **Isolation** window lists what stops working. Local sockets: with Network **Open**, a This-folder-only pane reaches no ssh agent and no Docker, colima, OrbStack, Rancher Desktop, lima or podman socket, so `git` over ssh and `docker` do not work there. With **Only these providers** or **None**, a pane reaches no Unix socket but nerd's message socket. diff --git a/docs/releasing.md b/docs/releasing.md index 3675a3f..4cb1e62 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -48,6 +48,23 @@ pull request, `chore(master): release X.Y.Z`. Replace `N` and `X.Y.Z` below with scripts/publish-binary.sh vX.Y.Z ``` + It stops before building if the draft's target is not a commit id ("not a commit id"), the tag's commit is not + on master ("is not on master"), or it is not the release commit itself, which changes only the version and adds + to the changelog ("is not a release commit", "changes more than nerd's version", "removes lines"); and after + building, before anything is signed, if the built nerd reports another version ("the built nerd says"). Any of + these means someone moved the tag or changed the release pull request: stop and look at who did, never work around + it. Other stops are ordinary: a failing test (it prints cargo test's `failures:`), "the tree must be clean", and + network errors from git or gh; fix the cause and run it again. + + Building and testing run every dependency's build code as you. Before building, the script reads the release + commit's `scripts/sign-release.sh`, `scripts/publish-formula.sh` and `install.sh` from git and later runs that text, + not the files on disk; after the build it runs only the system's own commands and the nerd it built until the key + has been used. A build that rewrites a script or puts a command of its own on your PATH cannot run in place of these + steps. What it cannot stop: build code that keeps running can read the terminal while you type the passphrase, use + your gh login without a prompt (upload to the draft, write the tap), change the binary being built, or wait for a + later release. Only building releases as a separate macOS user closes that; if a run looks wrong, see "If a release + run looks wrong". + 6. Check it: `false`, then the archive, its `.sha256` and its `.sha256.sig`: ```sh @@ -55,7 +72,7 @@ pull request, `chore(master): release X.Y.Z`. Replace `N` and `X.Y.Z` below with ``` If the formula step failed, run it alone: it reads the published checksum and checks its signature against this -checkout's install.sh, so running it again is safe. +checkout's install.sh, so running it again is safe once `git status --porcelain` prints nothing. `scripts/publish-formula.sh --dry-run vX.Y.Z` prints the formula without writing it. ```sh @@ -65,6 +82,32 @@ scripts/publish-formula.sh vX.Y.Z `release not found` in step 5 means step 3 or 4 is not done yet. No macOS runners in CI: the binary is built only on the maintainer's Mac, at the tag. +## If a release run looks wrong + +Step 5 asks for the passphrase once. If it asks a second time, if the release gets an asset or nerdterm/homebrew-tap a +commit that the run did not report, or if it stops anywhere step 5 does not name, take it that code from the build is +still running as you: + +- Do not type the passphrase, and do not run step 5 again. +- If the passphrase was typed, treat the key as leaked: move to the backup key as "If the main key leaks or is lost" + below says. +- Clean up from github.com on another device, or on this Mac only after what was left behind is removed and the Mac + restarted: a new `gh auth login` before that hands the new token to whatever is still running. +- Revoke GitHub CLI's token on github.com (Settings, Applications). +- Look for what was left behind: `~/.zshrc`, `~/.zprofile`, `~/Library/LaunchAgents`, `~/.cargo/bin`, `~/.local/bin`, + `/opt/homebrew/bin`, the checkout's `.git/config` and `.git/hooks`, and `~/.gitconfig` (a `core.fsmonitor` there runs + on the next `git status`). +- Put the release back to draft, then delete its assets: on github.com, or with `gh release edit vX.Y.Z --draft=true` + and `gh release delete-asset vX.Y.Z ` for each name step 6 lists. +- In nerdterm/homebrew-tap, revert `Formula/nerd.rb` to the last commit the release script made. + +## Dependency advisories + +`.github/workflows/advisories.yml` runs `cargo deny check advisories` every Monday and on demand +(`gh workflow run advisories.yml`); it is never part of `pr`. A red scheduled run emails the account that last changed +its cron line. Update the dependency named; a notice with no fix (an unmaintained crate) gets an `ignore` entry with its +reason in `deny.toml`. + ## The release key Releases are signed with an ECDSA P-256 key kept off GitHub. install.sh carries the public halves of it and of a diff --git a/install.sh b/install.sh index 2f0c16e..1dc030a 100644 --- a/install.sh +++ b/install.sh @@ -44,7 +44,7 @@ else # NERD_RELEASE_API lets tests point at a local stand-in; users never set it. api="${NERD_RELEASE_API:-https://api.github.com/repos/$repo/releases/latest}" # Into a file first: piping straight to curl would lose its exit status. - curl -fsSL "$api" -o "$work/release.json" 2>/dev/null || die "cannot read the latest release of $repo" + curl -fsSL --proto =https --proto-redir =https "$api" -o "$work/release.json" 2>/dev/null || die "cannot read the latest release of $repo" tag=$(grep -o '"tag_name": *"[^"]*"' "$work/release.json" | head -n 1 | cut -d'"' -f4 || true) printf '%s' "$tag" | grep -Eqx 'v[0-9]+\.[0-9]+\.[0-9]+' || die "the latest release's tag is not a version: $tag" # ceiling: a first install has no version to compare with, and this script itself comes from nerdterm.dev; an update from nerd's menu has neither limit. @@ -56,7 +56,7 @@ else # The three files of that tag, by name: nothing else in the release is looked at. name="nerd-$tag-macos-arm64.tar.gz" for n in "$name" "$name.sha256" "$name.sha256.sig"; do - curl -fsSL -o "$work/$n" "https://github.com/$repo/releases/download/$tag/$n" || die "download failed: $n" + curl -fsSL --proto =https --proto-redir =https -o "$work/$n" "https://github.com/$repo/releases/download/$tag/$n" || die "download failed: $n" done archive="$work/$name" fi @@ -91,6 +91,15 @@ fi tar -xzf "$archive" -C "$work" [ -f "$work/nerd" ] || die "the archive holds no nerd binary" xattr -d com.apple.quarantine "$work/nerd" 2>/dev/null || true +# A signed name holds the nerd of that version and no other: a release commit moved under another tag is refused. +case $base in + nerd-v*-macos-arm64.tar.gz) + want=${base#nerd-v} want=${want%-macos-arm64.tar.gz} + got=$("$work/nerd" --version 2>/dev/null) || die "the archive's nerd does not run, nothing installed" + got=$(printf '%s\n' "$got" | awk 'NR == 1 { print $2 }') + [ "$got" = "$want" ] || die "the archive holds nerd $got, not $want, nothing installed" + ;; +esac mkdir -p "$bindir" # Anyone who can write a folder on the way to nerd can rename it and put their own nerd in its place. Symlinks: ls on "$d/." reads the folder a link points to, the one the install writes through; the folders above that target are not checked. d=$bindir diff --git a/scripts/gates.sh b/scripts/gates.sh index d59f0c9..23f9595 100755 --- a/scripts/gates.sh +++ b/scripts/gates.sh @@ -11,6 +11,7 @@ sh scripts/terminals.test.sh sh scripts/publish-formula.test.sh sh scripts/install.test.sh sh scripts/sign-release.test.sh +sh scripts/publish-binary.test.sh # Cargo never deletes old builds: a target/ another toolchain built (they share nothing) or past 4 GB starts over. if [ -f target/.rustc_info.json ] && { ! grep -qF -- "$(rustc -V)" target/.rustc_info.json || [ "$(du -sk target | cut -f1)" -gt 4194304 ]; }; then cargo clean diff --git a/scripts/install.test.sh b/scripts/install.test.sh index e72005a..ed8ab51 100755 --- a/scripts/install.test.sh +++ b/scripts/install.test.sh @@ -26,11 +26,12 @@ awk -v m="$t/main.pub" -v b="$t/backup.pub" ' /^# release keys end/ { skip = 0 } !skip' "$here/install.sh" >"$t/install.sh" -# A release of `version` in `dir`: its archive, the shasum line, and that line signed with `key` (none for no .sig). +# A release of `version` in `dir`: its archive, the shasum line, and that line signed with `key` (none for no .sig); +# its nerd runs `body`, by default printing that version. release() { dir=$1 version=$2 key=$3 mkdir -p "$dir/build" - printf '#!/bin/sh\necho "nerd %s (test)"\n' "$version" >"$dir/build/nerd" + printf '#!/bin/sh\n%s\n' "${4:-echo \"nerd $version (test)\"}" >"$dir/build/nerd" chmod 755 "$dir/build/nerd" name="nerd-v$version-macos-arm64.tar.gz" tar -czf "$dir/$name" -C "$dir/build" nerd @@ -96,11 +97,18 @@ sign "$a.sha256" main expect "$(A=$a run named)" none "a signed checksum naming another archive" a=$(release "$g/down" 0.98.0 main) expect "$(HAVE=0.99.0 A=$a run down)" 0.98.0 "an older archive given by hand" +a=$(release "$g/moved" 1.2.3 main 'echo "nerd 1.2.0 (test)"') +expect "$(A=$a run moved)" none "an archive for 1.2.3 holding nerd 1.2.0" +grep -qx 'nerd: the archive holds nerd 1.2.0, not 1.2.3, nothing installed' "$t/out" || bad "another version: $(cat "$t/out")" +a=$(release "$g/broken" 0.99.0 main 'exit 3') +expect "$(A=$a run broken)" none "an archive whose nerd does not run" +grep -qx "nerd: the archive's nerd does not run, nothing installed" "$t/out" || bad "does not run: $(cat "$t/out")" # The download path, through a stand-in curl serving $SERVED by the last part of the url. mkdir -p "$t/stub" cat >"$t/stub/curl" <<'EOF' #!/bin/sh +echo "$*" >>"$SERVED/args" out= url= while [ $# -gt 0 ]; do case $1 in -o) out=$2; shift ;; -*) ;; *) url=$1 ;; esac; shift; done echo "$url" >>"$SERVED/asked" @@ -116,6 +124,7 @@ rm -f "$s/asked" expect "$(dl latest)" 1.2.3 "the latest release" [ "$(grep -c 'v1\.2\.0' "$s/asked")" = 0 ] || bad "the download path fetched another release's files" [ "$(grep -c '/releases/download/v1\.2\.3/' "$s/asked")" = 3 ] || bad "the download path did not fetch exactly the tag's three files" +grep -v -e '--proto =https --proto-redir =https' "$s/args" && bad "a download may leave https" expect "$(HAVE=1.3.0 dl older)" 1.3.0 "a latest release older than the installed nerd" expect "$(HAVE=1.2.3 dl same)" 1.2.3 "the installed version again" mv "$s/nerd-v1.2.3-macos-arm64.tar.gz.sha256.sig" "$t/saved.sig" @@ -125,6 +134,10 @@ mv "$t/saved.sig" "$s/nerd-v1.2.3-macos-arm64.tar.gz.sha256.sig" release "$s" 1.2 main >/dev/null printf '{"tag_name": "v1.2", "assets": []}\n' >"$s/latest" expect "$(dl tag)" none "a tag that is not a version" +release "$s" 1.2.3 main 'echo "nerd 1.2.0 (test)"' >/dev/null +printf '{"tag_name": "v1.2.3", "assets": []}\n' >"$s/latest" +expect "$(dl moved)" none "the latest release's archive holding nerd 1.2.0" +grep -qx 'nerd: the archive holds nerd 1.2.0, not 1.2.3, nothing installed' "$t/out" || bad "download, another version: $(cat "$t/out")" # A folder above the install folder writable by others is named once, with its fix; a normal home prints nothing about it. a=$(release "$g/perm" 0.99.0 main) diff --git a/scripts/publish-binary.sh b/scripts/publish-binary.sh index 958a03e..1f11c6c 100755 --- a/scripts/publish-binary.sh +++ b/scripts/publish-binary.sh @@ -1,43 +1,81 @@ #!/bin/sh # Run from a plain terminal after merging the release pull request: gh needs the keychain. set -eu -tag=${1:?usage: scripts/publish-binary.sh vX.Y.Z} -cd "$(git rev-parse --show-toplevel)" -[ -z "$(git status --porcelain)" ] || { echo "publish: the tree must be clean" >&2; exit 1; } -git fetch -q --tags origin -# A draft release has no git tag until it is published: build its target commit. -ref=$tag -if ! git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then - ref=$(gh release view "$tag" --repo nerdterm/nerd --json targetCommitish -q .targetCommitish) - git fetch -q origin "$ref" -fi -# Under target/, not the temp folders: the isolation tests need a checkout a This-folder-only pane cannot write. -mkdir -p target -work=$(mktemp -d "$PWD/target/publish.XXXXXX") -trap 'rm -rf "$work"' EXIT -git worktree add -q --detach "$work/src" "$ref" -fresh=$(mktemp -d) -# Resolved before HOME changes: /bin/sh expands later prefix assignments with the new HOME. -cargo_home=${CARGO_HOME:-$HOME/.cargo} -rustup_home=${RUSTUP_HOME:-$HOME/.rustup} -(cd "$work/src" \ - && cargo build --release --locked --target-dir "$work/target" \ - && HOME="$fresh" CARGO_HOME="$cargo_home" RUSTUP_HOME="$rustup_home" \ - cargo test --release --locked --target-dir "$work/target" >"$work/tests.log" \ - || { sed -n '/^failures:/,$p' "$work/tests.log" >&2; exit 1; }) -rm -rf "$fresh" -bin="$work/target/release/nerd" -codesign --force --sign - "$bin" -name="nerd-$tag-macos-arm64.tar.gz" -tar -czf "$work/$name" -C "$work/target/release" nerd -# Signed with the release key, then installed by this tag's own install.sh into a scratch folder: a release its users would refuse never leaves. -sh scripts/sign-release.sh "$work/$name" "$work/src/install.sh" -gh release upload "$tag" --repo nerdterm/nerd "$work/$name" "$work/$name.sha256" "$work/$name.sha256.sig" --clobber -# The bot leaves the release as a draft; this asset is what publishes it. -gh release edit "$tag" --repo nerdterm/nerd --draft=false -git worktree remove --force "$work/src" -echo "attached $name to $tag and published it" -# nerdterm.dev is built from the latest release: rebuild it now rather than at the next daily run. -gh workflow run pages --repo nerdterm/nerd-plugins -echo "started the nerdterm.dev build" -sh scripts/publish-formula.sh "$tag" +# All of it in main, called by the last line: /bin/sh rereads a running script from disk, which the build can rewrite. +main() { + tag=${1:?usage: scripts/publish-binary.sh vX.Y.Z} + cd "$(git rev-parse --show-toplevel)" + [ -z "$(git status --porcelain)" ] || { echo "publish: the tree must be clean" >&2; exit 1; } + git fetch -q --tags origin + # A draft release has no git tag until it is published: build its target commit. + stop() { echo "publish: $*; stop and look at who changed it" >&2; exit 1; } + ref=$tag + if ! git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + ref=$(gh release view "$tag" --repo nerdterm/nerd --json targetCommitish -q .targetCommitish) + printf '%s' "$ref" | grep -Eqx '[0-9a-f]{40}' || stop "the draft $tag targets $ref, not a commit id" + git fetch -q origin "$ref" + fi + # Only the release commit itself is built and signed: on master, changing the version and the changelog and nothing + # else. Whoever can move a tag, edit a draft or push to the release pull request's branch cannot choose the commit. + git fetch -q origin master + want=${tag#v} + d() { git diff --no-color --no-ext-diff --no-renames "$@"; } + sha=$(git rev-parse -q --verify "$ref^{commit}") || stop "$ref is not a commit" + git merge-base --is-ancestor "$sha" origin/master || stop "$tag ($sha) is not on master" + files=$(d --name-only "$sha^" "$sha" | LC_ALL=C sort | tr '\n' ' ') + [ "$files" = ".release-please-manifest.json CHANGELOG.md Cargo.lock Cargo.toml " ] || stop "$tag ($sha) is not a release commit: it changes $files" + was=$(git show "$sha^:Cargo.toml" | sed -n 's/^version = "\(.*\)"$/\1/p' | head -n 1) + # Counted, not read off the diff's text: a line of content may itself begin with "--" or "++". + for f in Cargo.toml Cargo.lock; do + [ "$(d --numstat "$sha^" "$sha" -- "$f")" = "$(printf '1\t1\t%s' "$f")" ] \ + && d -U0 "$sha^" "$sha" -- "$f" | grep -Fqx -- "-version = \"$was\"" \ + && d -U0 "$sha^" "$sha" -- "$f" | grep -Fqx -- "+version = \"$want\"" \ + || stop "$f at $tag changes more than nerd's version from $was to $want" + done + [ "$(d --numstat "$sha^" "$sha" -- CHANGELOG.md | cut -f2)" = 0 ] || stop "CHANGELOG.md at $tag removes lines" + # Under target/, not the temp folders: the isolation tests need a checkout a This-folder-only pane cannot write. + mkdir -p target + work=$(mktemp -d "$PWD/target/publish.XXXXXX") + trap 'rm -rf "$work"' EXIT + # Read from git before the build, which can rewrite any file of this user: what runs after it is the release's text. + sign_src=$(git cat-file blob "$sha:scripts/sign-release.sh") + formula_src=$(git cat-file blob "$sha:scripts/publish-formula.sh") + install_src=$(git cat-file blob "$sha:install.sh") + git worktree add -q --detach "$work/src" "$sha" + fresh=$(mktemp -d) + # Resolved before HOME changes: /bin/sh expands later prefix assignments with the new HOME. + cargo_home=${CARGO_HOME:-$HOME/.cargo} + rustup_home=${RUSTUP_HOME:-$HOME/.rustup} + user_path=$PATH + (cd "$work/src" \ + && cargo build --release --locked --target-dir "$work/target" \ + && HOME="$fresh" CARGO_HOME="$cargo_home" RUSTUP_HOME="$rustup_home" \ + cargo test --release --locked --target-dir "$work/target" >"$work/tests.log" \ + || { sed -n '/^failures:/,$p' "$work/tests.log" >&2; exit 1; }) + # From here on only the system's commands: the build may have planted others in a folder on the user's PATH. + # gh and the formula get that PATH back, after the key. + PATH=/usr/bin:/bin:/usr/sbin:/sbin + rm -rf "$fresh" + # Made only now, so the build never saw it; mkdir fails if anything made it first. + pin="$work/pin" + mkdir "$pin" + printf '%s\n' "$install_src" >"$pin/install" + bin="$work/target/release/nerd" + # Before the key is asked for: what was built is the version being published. + [ "$("$bin" --version | awk '{ print $2 }')" = "$want" ] || stop "the built nerd says $("$bin" --version), not $want" + codesign --force --sign - "$bin" + name="nerd-$tag-macos-arm64.tar.gz" + tar -czf "$work/$name" -C "$work/target/release" nerd + # Signed with the release key, then installed by this tag's own install.sh into a scratch folder: a release its users would refuse never leaves. + /bin/sh -c "$sign_src" sign-release "$work/$name" "$pin/install" + PATH=$user_path gh release upload "$tag" --repo nerdterm/nerd "$work/$name" "$work/$name.sha256" "$work/$name.sha256.sig" --clobber + # The bot leaves the release as a draft; this asset is what publishes it. + PATH=$user_path gh release edit "$tag" --repo nerdterm/nerd --draft=false + git worktree remove --force "$work/src" + echo "attached $name to $tag and published it" + # nerdterm.dev is built from the latest release: rebuild it now rather than at the next daily run. + PATH=$user_path gh workflow run pages --repo nerdterm/nerd-plugins + echo "started the nerdterm.dev build" + NERD_INSTALL_SH="$pin/install" PATH=$user_path /bin/sh -c "$formula_src" publish-formula "$tag" +} +main "$@"; exit diff --git a/scripts/publish-binary.test.sh b/scripts/publish-binary.test.sh new file mode 100644 index 0000000..838689e --- /dev/null +++ b/scripts/publish-binary.test.sh @@ -0,0 +1,171 @@ +#!/bin/sh +# Runs the real publish-binary.sh in a scratch repository with stand-ins for gh, cargo and the signing and formula +# scripts: nothing is built, signed or sent anywhere. Each refusal is checked by its message and by gh's calls. +set -eu +here=$(cd "$(dirname "$0")" && pwd) +t=$(mktemp -d) +trap 'rm -rf "$t"' EXIT +fail=0 +bad() { echo "FAIL: $*" >&2; fail=1; } +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@example.com GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@example.com +# A gh that is not the stand-in must never be reached: no token, no config. +export GH_TOKEN= GH_CONFIG_DIR="$t/gh-config" + +mkdir -p "$t/stub" +cat >"$t/stub/gh" <<'EOF' +#!/bin/sh +echo "$*" >>"$STUB/gh.log" +case "$1 $2" in "release view") echo "$STUB_TARGET" ;; esac +EOF +cat >"$t/stub/cargo" <<'EOF' +#!/bin/sh +echo "$*" >>"$STUB/cargo.log" +[ "$1" = build ] || exit 0 +while [ $# -gt 0 ]; do [ "$1" = --target-dir ] && dir=$2; shift; done +mkdir -p "$dir/release" +printf '#!/bin/sh\necho "nerd %s (test)"\n' "${STUB_VERSION:-$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1)}" >"$dir/release/nerd" +chmod 755 "$dir/release/nerd" +[ -n "${STUB_HIJACK:-}" ] || exit 0 +# What a build script could do: append to every script run after it (the clone is three folders up) and plant +# commands in a folder on PATH. +top=$(cd ../../.. && pwd) +for f in "$top/scripts/publish-binary.sh" "$top/scripts/sign-release.sh" "$top/scripts/publish-formula.sh" "$top/install.sh" "$PWD/install.sh" "$(cd .. && pwd)/pin/install"; do + if [ -f "$f" ]; then printf 'echo %s >>"$STUB/hijacked"\n' "$f" >>"$f"; fi +done +for c in shasum dirname basename awk tar codesign rm mktemp; do + printf '#!/bin/sh\necho %s >>"$STUB/planted"\nPATH=/usr/bin:/bin exec %s "$@"\n' "$c" "$c" >"$STUB/plant/$c" + chmod 755 "$STUB/plant/$c" +done +EOF +chmod 755 "$t/stub/gh" "$t/stub/cargo" +export STUB="$t" PATH="$t/stub:$PATH" +[ "$(command -v gh)" = "$t/stub/gh" ] || { echo "FAIL: gh is not the stand-in" >&2; exit 1; } + +# origin: a parent, its release commit to 0.2.0, a later master commit, one more release that also touches build.rs, +# and a release-shaped commit on a branch that never reached master. +o="$t/origin.git" +git init -q --bare -b master "$o" +w="$t/make" +git clone -q "$o" "$w" 2>/dev/null +mkdir -p "$w/scripts" +cp "$here/publish-binary.sh" "$w/scripts/" +# The real signing script's commands, by name, so a planted one would show. +cat >"$w/scripts/sign-release.sh" <<'EOF' +#!/bin/sh +echo signed >>"$STUB/sign.log" +(cd "$(dirname "$1")" && shasum -a 256 "$(basename "$1")") >"$1.sha256" +touch "$1.sha256.sig" +sh "$2" "$1" +EOF +cat >"$w/scripts/publish-formula.sh" <<'EOF' +#!/bin/sh +echo "$*" >>"$STUB/formula.log" +cat "${NERD_INSTALL_SH:-/dev/null}" >"$STUB/formula-install" +EOF +printf '#!/bin/sh\necho installed >>"$STUB/install.log"\n' >"$w/install.sh" +printf 'target/\n' >"$w/.gitignore" +version() { # files of release $1 with history $2 + printf '[package]\nname = "nerd"\nversion = "%s"\n' "$1" >"$w/Cargo.toml" + printf '[[package]]\nname = "nerd"\nversion = "%s"\n' "$1" >"$w/Cargo.lock" + printf '{".": "%s"}\n' "$1" >"$w/.release-please-manifest.json" + { printf '## %s\n' "$1"; cat "$w/CHANGELOG.md" 2>/dev/null || true; } >"$t/changelog" && mv "$t/changelog" "$w/CHANGELOG.md" +} +commit() { git -C "$w" add -A && git -C "$w" commit -q -m "$1" && git -C "$w" rev-parse HEAD; } +version 0.1.0 +echo src >"$w/main.rs" +commit parent >/dev/null +version 0.2.0 +release=$(commit 'release 0.2.0') +echo more >>"$w/main.rs" +later=$(commit 'a fix after the release') +version 0.3.0 +echo 'fn main() {}' >"$w/build.rs" +touched=$(commit 'release 0.3.0, and build.rs') +# Release-shaped commits each hiding one more change, every one after a clean commit of the version before it. +version 0.4.0 +printf '[[package]]\nname = "evil"\nversion = "1.0.0"\n' >>"$w/Cargo.lock" +lock=$(commit 'release 0.4.0, and a package in Cargo.lock') +version 0.4.0 +commit 'clean 0.4.0' >/dev/null +version 0.5.0 +printf '++ injected\n' >>"$w/Cargo.toml" +toml=$(commit 'release 0.5.0, and a line in Cargo.toml') +version 0.5.0 +printf -- '-- hidden\n' >>"$w/CHANGELOG.md" +commit 'clean 0.5.0' >/dev/null +version 0.6.0 +grep -v -e '^-- hidden$' "$w/CHANGELOG.md" >"$t/changelog" && mv "$t/changelog" "$w/CHANGELOG.md" +log=$(commit 'release 0.6.0, and a changelog line gone') +git -C "$w" push -q origin master +git -C "$w" switch -q -c side "$release^" +version 0.2.0 +side=$(commit 'release-shaped, never on master') +git -C "$w" push -q origin side + +# publish TAG: a fresh clone runs the real script; the tag, if any, points where $at says. +publish() { + rm -rf "$t/gh.log" "$t/cargo.log" "$t/sign.log" "$t/formula.log" "$t/formula-install" "$t/hijacked" "$t/planted" "$t/plant" + mkdir "$t/plant" + git -C "$o" tag -d "$1" >/dev/null 2>&1 || true + [ -z "${at:-}" ] || git -C "$o" tag "$1" "$at" + c="$t/clone-$1-$(date +%s%N)" + git clone -q "$o" "$c" + (cd "$c" && PATH="$t/plant:$PATH" sh scripts/publish-binary.sh "$1") >"$t/out" 2>&1 && rc=0 || rc=$? + cat "$t/out" + # A prefix assignment before a function call outlives it in sh: each case starts clean. + unset at STUB_VERSION STUB_TARGET STUB_HIJACK + return "$rc" +} +uploaded() { grep -q '^release upload' "$t/gh.log" 2>/dev/null; } +expect_refused() { # case, message + if uploaded; then bad "$1: uploaded"; fi + grep -q "$2" "$t/out" || bad "$1: no \"$2\" in: $(cat "$t/out")" +} + +at=$release publish v0.2.0 >/dev/null || bad "(a) the release commit: $(cat "$t/out")" +uploaded || bad "(a) the release commit was not uploaded" +[ -f "$t/sign.log" ] || bad "(a) not signed" + +at=$side publish v0.2.0 >/dev/null && bad "(b) published a commit off master" +expect_refused "(b) off master" "is not on master" + +at=$later publish v0.2.0 >/dev/null && bad "(c) published a later master commit" +expect_refused "(c) moved to a later commit" "is not a release commit" + +at=$touched publish v0.3.0 >/dev/null && bad "(d) published a release that touches build.rs" +expect_refused "(d) build.rs" "is not a release commit" + +STUB_VERSION=0.1.9 at=$release publish v0.2.0 >/dev/null && bad "(e) published a binary of another version" +expect_refused "(e) another binary" "the built nerd says" +[ ! -f "$t/sign.log" ] || bad "(e) signed before the version check" + +STUB_TARGET=$release at= publish v0.2.0 >/dev/null || bad "(f) the draft's release commit: $(cat "$t/out")" +uploaded || bad "(f) the draft's release commit was not uploaded" + +STUB_TARGET=$side at= publish v0.2.0 >/dev/null && bad "(g) published a draft target off master" +expect_refused "(g) draft off master" "is not on master" + +at=$release publish v0.5.0 >/dev/null && bad "(h) published a release commit under another version" +expect_refused "(h) another version" "changes more than nerd's version" +[ ! -f "$t/cargo.log" ] || bad "(h) built before the version check" + +at=$lock publish v0.4.0 >/dev/null && bad "(i) published a release with another package in Cargo.lock" +expect_refused "(i) Cargo.lock" "Cargo.lock at v0.4.0 changes more than nerd's version" + +at=$toml publish v0.5.0 >/dev/null && bad "(j) published a release with a line beginning ++ in Cargo.toml" +expect_refused "(j) Cargo.toml" "Cargo.toml at v0.5.0 changes more than nerd's version" + +at=$log publish v0.6.0 >/dev/null && bad "(k) published a release whose changelog lost a line beginning --" +expect_refused "(k) CHANGELOG.md" "CHANGELOG.md at v0.6.0 removes lines" + +STUB_TARGET=master at= publish v0.2.0 >/dev/null && bad "(l) published a draft whose target is a branch name" +expect_refused "(l) draft target" "targets master, not a commit id" + +STUB_HIJACK=1 at=$release publish v0.2.0 >/dev/null || bad "(m) the release commit, with a build that rewrites scripts: $(cat "$t/out")" +uploaded || bad "(m) the release commit was not uploaded" +[ ! -f "$t/hijacked" ] || bad "(m) ran what the build appended to: $(tr '\n' ' ' <"$t/hijacked")" +[ ! -f "$t/planted" ] || bad "(m) ran commands the build planted: $(tr '\n' ' ' <"$t/planted")" +cmp -s "$t/formula-install" "$w/install.sh" || bad "(m) the formula did not read the release commit's install.sh" + +[ "$fail" = 0 ] && echo "publish-binary: 13 cases pass" +exit "$fail" diff --git a/src/app/lifecycle.rs b/src/app/lifecycle.rs index 7ab84db..4fb8236 100644 --- a/src/app/lifecycle.rs +++ b/src/app/lifecycle.rs @@ -753,9 +753,12 @@ impl App { /// What git says about a folder, asked once when a space opens there, before any frame shows it. pub(super) fn learn_repo(&mut self, cwd: &std::path::Path) { if !self.repos.contains_key(cwd) { - let key = crate::git::group_key(cwd); + // git runs here on the loop: a HEAD that cannot be read as a small file (a FIFO a pane made) would hold it until git's timeout. + // ceiling: a FIFO elsewhere in .git (config) still holds the loop up to 10 s once per folder; asking on the git worker is the way past. + let branch = crate::chrome::git_branch(cwd); + let key = branch.as_ref().and_then(|_| crate::git::group_key(cwd)); let main = key.is_some() && crate::git::is_main_checkout(cwd); - self.repos.insert(cwd.to_path_buf(), Repo { key, main, branch: crate::chrome::git_branch(cwd) }); + self.repos.insert(cwd.to_path_buf(), Repo { key, main, branch }); } } diff --git a/src/app/mod.rs b/src/app/mod.rs index f199fac..8bb9962 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1932,6 +1932,23 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn a_repo_whose_head_a_pane_made_a_fifo_is_not_asked_on_the_loop() { + let dir = crate::git::tests::init_repo("app-fifo-head"); + let mut app = App::new(Config::default()); + app.learn_repo(&dir); + assert!(app.repos[&dir].key.is_some(), "a plain repository is grouped"); + let fifo = crate::git::tests::init_repo("app-fifo-head-2"); + std::fs::remove_file(fifo.join(".git/HEAD")).unwrap(); + nix::unistd::mkfifo(&fifo.join(".git/HEAD"), nix::sys::stat::Mode::S_IRWXU).unwrap(); + let started = Instant::now(); + app.learn_repo(&fifo); + assert!(started.elapsed() < Duration::from_secs(2), "git was not run: {:?}", started.elapsed()); + assert!(app.repos[&fifo].key.is_none() && app.repos[&fifo].branch.is_none()); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&fifo); + } + #[test] fn the_changes_view_shows_what_the_git_worker_found() { let dir = crate::git::tests::init_repo("app-changes"); diff --git a/src/chrome.rs b/src/chrome.rs index 898adc9..9d748ec 100644 --- a/src/chrome.rs +++ b/src/chrome.rs @@ -368,29 +368,142 @@ pub fn draw_tabs_keeping(buf: &mut Buffer, area: Rect, tabs: &[TabItem], keys: b /// Reads .git/HEAD directly - running git every frame is too expensive. None outside a repository. pub fn git_branch(dir: &Path) -> Option { - let git_dir = find_git_dir(dir)?; - let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?; - parse_head(&head) + let git_dir = find_git_dir(dir, automount_roots())?; + parse_head(&read_small(&git_dir.join("HEAD"))?) } /// Finds `.git` (or its target for a worktree/submodule), walking up since a pane may be in a subfolder. -fn find_git_dir(start: &Path) -> Option { +/// A pane writes its folder: a `.git` link or `gitdir:` into an automounted folder would stall the frame on the mount. +fn find_git_dir(start: &Path, roots: &[PathBuf]) -> Option { let mut dir = start; loop { let candidate = dir.join(".git"); - if candidate.is_dir() { - return Some(candidate); - } - if candidate.is_file() { - let content = std::fs::read_to_string(&candidate).ok()?; - let gitdir = content.trim().strip_prefix("gitdir:")?.trim(); - let gitdir = PathBuf::from(gitdir); - return Some(if gitdir.is_absolute() { gitdir } else { dir.join(gitdir) }); + if let Ok(m) = std::fs::symlink_metadata(&candidate) { + // Checked by name, then handed to the file system as written: a folder reached through a link resolves `..` its own way. + let mut target = candidate.clone(); + if m.is_symlink() { + for _ in 0..8 { + let Ok(next) = std::fs::read_link(&target) else { break }; + target = lexical(&target.parent().unwrap_or(dir).join(next)); + if under(&target, roots) { + return None; + } + } + } + if candidate.is_dir() { + return Some(candidate); + } + if candidate.is_file() { + let file = if m.is_symlink() { std::fs::canonicalize(&candidate).ok()? } else { candidate }; + let gitdir = dir.join(read_small(&file)?.trim().strip_prefix("gitdir:")?.trim()); + return resolve(&gitdir, roots); + } } dir = dir.parent()?; } } +/// `p` with every link on the way followed by name (at most 8), each step checked against `roots` before the file system is asked about it. +fn resolve(p: &Path, roots: &[PathBuf]) -> Option { + use std::path::Component; + let mut rest = p.to_path_buf(); + let mut hops = 0; + 'walk: loop { + let mut out = PathBuf::new(); + let mut comps = rest.components(); + while let Some(c) = comps.next() { + match c { + Component::ParentDir => { + out.pop(); + } + Component::CurDir => {} + Component::Normal(_) => { + out.push(c); + if under(&out, roots) { + return None; + } + if std::fs::symlink_metadata(&out).is_ok_and(|m| m.is_symlink()) { + hops += 1; + if hops > 8 { + return None; + } + let next = std::fs::read_link(&out).ok()?; + out.pop(); + // The target is walked in turn, from here (or from the root, if absolute), then what was left. + rest = out.join(next).join(comps.as_path()); + continue 'walk; + } + } + c => out.push(c), + } + } + return (!under(&out, roots)).then_some(out); + } +} + +/// A small regular file's text: a FIFO, a device, a link or a huge file in a pane's folder must not block the frame or fill memory. +fn read_small(path: &Path) -> Option { + use std::io::Read as _; + use std::os::unix::fs::OpenOptionsExt as _; + let f = std::fs::OpenOptions::new().read(true).custom_flags(nix::libc::O_NONBLOCK | nix::libc::O_NOCTTY | nix::libc::O_NOFOLLOW).open(path).ok()?; + if !f.metadata().ok()?.is_file() { + return None; + } + let mut s = String::new(); + f.take(4096).read_to_string(&mut s).ok()?; + Some(s) +} + +/// Under one of `roots`, ignoring case as the file system does (`/HOME` is `/home`). +fn under(p: &Path, roots: &[PathBuf]) -> bool { + let p = PathBuf::from(p.to_string_lossy().to_lowercase()); + roots.iter().any(|r| p.starts_with(r.to_string_lossy().to_lowercase())) +} + +/// `p` with `.` and `..` resolved by name, never by asking the file system. +fn lexical(p: &Path) -> PathBuf { + use std::path::Component; + let mut out = PathBuf::new(); + for c in p.components() { + match c { + Component::ParentDir => { + out.pop(); + } + Component::CurDir => {} + c => out.push(c), + } + } + out +} + +/// The automounters' folders: /net, /home, and every autofs mount, also by its firmlinked name outside /System/Volumes/Data. +fn automount_roots() -> &'static [PathBuf] { + static ROOTS: std::sync::OnceLock> = std::sync::OnceLock::new(); + ROOTS.get_or_init(|| { + use nix::libc; + let mut roots = vec![PathBuf::from("/net"), PathBuf::from("/home")]; + // SAFETY: a null buffer asks for the count; the second call fills at most the room it is given, zeroed beforehand. + let n = unsafe { libc::getfsstat(std::ptr::null_mut(), 0, libc::MNT_NOWAIT) }; + if n > 0 { + let mut buf = vec![unsafe { std::mem::zeroed::() }; n as usize + 8]; + let size = (buf.len() * std::mem::size_of::()) as libc::c_int; + let got = unsafe { libc::getfsstat(buf.as_mut_ptr(), size, libc::MNT_NOWAIT) }; + for s in buf.iter().take(got.max(0) as usize) { + // SAFETY: both names are NUL-terminated by the kernel within their arrays. + let (kind, on) = unsafe { (std::ffi::CStr::from_ptr(s.f_fstypename.as_ptr()), std::ffi::CStr::from_ptr(s.f_mntonname.as_ptr())) }; + if kind.to_bytes() == b"autofs" { + let on = PathBuf::from(on.to_string_lossy().into_owned()); + if let Ok(short) = on.strip_prefix("/System/Volumes/Data") { + roots.push(Path::new("/").join(short)); + } + roots.push(on); + } + } + } + roots + }) +} + fn parse_head(content: &str) -> Option { let content = content.trim(); if let Some(branch) = content.strip_prefix("ref: refs/heads/") { @@ -617,6 +730,100 @@ pub(crate) mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + #[test] + fn read_small_reads_a_little_of_a_regular_file_and_nothing_else() { + let dir = std::env::temp_dir().join(format!("nerd-chrome-test-small-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("big"), "x".repeat(8192)).unwrap(); + assert_eq!(read_small(&dir.join("big")).map(|s| s.len()), Some(4096)); + assert_eq!(read_small(Path::new("/dev/zero")), None, "a device"); + std::os::unix::fs::symlink(dir.join("big"), dir.join("link")).unwrap(); + assert_eq!(read_small(&dir.join("link")), None, "a link"); + nix::unistd::mkfifo(&dir.join("fifo"), nix::sys::stat::Mode::S_IRWXU).unwrap(); + let fifo = dir.join("fifo"); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(read_small(&fifo)).unwrap()); + assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(5)), Ok(None), "a FIFO returns at once, unread"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// A real folder stands in for the mount, so each case would read its branch if the guard were gone. + #[test] + fn a_repo_pointing_into_an_automounted_folder_is_not_followed() { + let base = std::env::temp_dir().join(format!("nerd-chrome-test-mnt-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + // Canonical, as the lexical paths compared with the mount are. + let base = base.canonicalize().unwrap(); + let mnt = base.join("mnt"); + std::fs::create_dir_all(mnt.join("repo")).unwrap(); + std::fs::write(mnt.join("repo/HEAD"), "ref: refs/heads/mounted\n").unwrap(); + let target = mnt.join("repo"); + for case in ["gitdir", "upper-gitdir", "relative-gitdir", "link"] { + let dir = base.join(case); + std::fs::create_dir_all(&dir).unwrap(); + match case { + "gitdir" => std::fs::write(dir.join(".git"), format!("gitdir: {}\n", target.display())).unwrap(), + // The volume ignores case, so the mount is reached by any spelling of its name. + "upper-gitdir" => std::fs::write(dir.join(".git"), format!("gitdir: {}\n", target.display().to_string().to_uppercase())).unwrap(), + "relative-gitdir" => std::fs::write(dir.join(".git"), "gitdir: ../mnt/./repo\n").unwrap(), + _ => std::os::unix::fs::symlink(&target, dir.join(".git")).unwrap(), + } + assert!(find_git_dir(&dir, &[]).is_some(), "{case}: reachable without the guard"); + assert_eq!(find_git_dir(&dir, std::slice::from_ref(&mnt)), None, "{case}"); + } + let head = base.join("head-link"); + std::fs::create_dir_all(head.join(".git")).unwrap(); + std::os::unix::fs::symlink(mnt.join("repo/HEAD"), head.join(".git/HEAD")).unwrap(); + assert_eq!(git_branch(&head), None, "a linked HEAD is not read"); + assert_eq!(lexical(Path::new("/a/b/../../../net//x/./y")), PathBuf::from("/net/x/y")); + assert!(under(Path::new("/HOME/u"), automount_roots()) && under(Path::new("/net/h"), automount_roots()) && !under(Path::new("/network"), automount_roots())); + // Every autofs mount the system lists is a root, by its own name. + let mounts = String::from_utf8(std::process::Command::new("/sbin/mount").output().unwrap().stdout).unwrap(); + for on in mounts.lines().filter(|l| l.contains("(autofs")).filter_map(|l| l.split(" on ").nth(1)?.split(" (").next()) { + assert!(automount_roots().iter().any(|r| r == Path::new(on)), "{on} in {:?}", automount_roots()); + } + std::fs::remove_dir_all(&base).unwrap(); + } + + /// A link inside the `gitdir:` path leads into the mount just as a link at its end does; links to ordinary folders still work. + #[test] + fn a_link_inside_a_gitdir_path_into_an_automounted_folder_is_not_followed() { + use std::os::unix::fs::symlink; + let base = std::env::temp_dir().join(format!("nerd-chrome-test-inner-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let base = base.canonicalize().unwrap(); + let mnt = base.join("mnt"); + std::fs::create_dir_all(mnt.join("repo")).unwrap(); + std::fs::write(mnt.join("repo/HEAD"), "ref: refs/heads/mounted\n").unwrap(); + let fine = base.join("fine/real"); + std::fs::create_dir_all(&fine).unwrap(); + std::fs::write(fine.join("HEAD"), "ref: refs/heads/wt\n").unwrap(); + let roots = std::slice::from_ref(&mnt); + let repo = |name: &str, gitdir: String| { + let d = base.join(name); + std::fs::create_dir_all(&d).unwrap(); + symlink(&mnt, d.join("lnk")).unwrap(); + symlink(d.join("lnk"), d.join("chain")).unwrap(); + symlink(base.join("fine"), d.join("ok")).unwrap(); + std::fs::write(d.join(".git"), format!("gitdir: {gitdir}\n")).unwrap(); + d + }; + for (name, gitdir) in [("inner", "lnk/repo"), ("chain", "chain/repo"), ("dotdot", "ok/../dotdot/lnk/repo")] { + let d = repo(name, gitdir.into()); + assert!(find_git_dir(&d, &[]).is_some(), "{name}: reachable without the guard"); + assert_eq!(find_git_dir(&d, roots), None, "{name}"); + } + let d = repo("legit", "ok/real".into()); + assert_eq!(find_git_dir(&d, roots).map(|g| g.join("HEAD")).and_then(|h| read_small(&h)).as_deref(), Some("ref: refs/heads/wt\n")); + let d = repo("plain", fine.display().to_string()); + assert_eq!(git_branch(&d), Some("wt".to_string())); + symlink("loop", base.join("loop")).unwrap(); + assert_eq!(resolve(&base.join("loop/x"), roots), None, "hop limit"); + std::fs::remove_dir_all(&base).unwrap(); + } + /// The window drawn in `buf`: from its top-left corner to its bottom-right one, and the text of its top row. fn frame(buf: &Buffer) -> (Rect, String) { let cells = |sym: &str| (0..buf.area.height).flat_map(|y| (0..buf.area.width).map(move |x| (x, y))).filter(|&p| buf[p].symbol() == sym).collect::>(); diff --git a/src/daemon.rs b/src/daemon.rs index a66fa5b..cb6cf1a 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -180,6 +180,8 @@ fn serve(started: &mut bool) -> std::io::Result<()> { } }; let listener_fd = listener.as_raw_fd(); + // What a crash left of this instance's preview conversions; only once the socket is ours, so never a running server's. + let _ = std::fs::remove_dir_all(state.join("preview")); let (tx, rx) = channel(); let (bell, door) = crate::bell::new(); diff --git a/src/git.rs b/src/git.rs index 24be2e3..c32d7a8 100644 --- a/src/git.rs +++ b/src/git.rs @@ -35,16 +35,25 @@ where /// A folder the user only opened: its own config may not run anything (fsmonitor, lazy fetch, and an /// index write, which runs post-index-change: diff ignores GIT_OPTIONAL_LOCKS, so autoRefreshIndex is off too). -// Known ceiling: a denylist over git's mechanisms; gits without GIT_NO_LAZY_FETCH still lazy-fetch, and stat-dirty files are re-hashed each call. +// Only helper `_` is allowed (no repo alias can be named remote-_, autocorrect is off; "" would allow helper "", answered by alias remote-), so no git lazy-fetches through the repo's transport. +// ceiling: a denylist over git's mechanisms; gits before 2.31 ignore filters_off's GIT_CONFIG_COUNT, and stat-dirty files are re-hashed each call; a git version floor lifts it. fn read_git(dir: &Path, args: I, env: &[(String, String)]) -> Result +where + I: IntoIterator, + S: AsRef, +{ + run(read_cmd(dir, args, env)) +} + +fn read_cmd(dir: &Path, args: I, env: &[(String, String)]) -> Command where I: IntoIterator, S: AsRef, { let mut cmd = crate::spawn::cmd("git"); - cmd.envs(env.iter().map(|(k, v)| (k, v))).env("GIT_OPTIONAL_LOCKS", "0").env("GIT_NO_LAZY_FETCH", "1").env("GIT_LITERAL_PATHSPECS", "1"); - cmd.arg("-C").arg(dir).args(["-c", "core.fsmonitor=false", "-c", "diff.autoRefreshIndex=false"]).args(args); - run(cmd) + cmd.envs(env.iter().map(|(k, v)| (k, v))).env("GIT_OPTIONAL_LOCKS", "0").env("GIT_NO_LAZY_FETCH", "1").env("GIT_LITERAL_PATHSPECS", "1").env("GIT_ALLOW_PROTOCOL", "_"); + cmd.arg("-C").arg(dir).args(["-c", "core.fsmonitor=false", "-c", "diff.autoRefreshIndex=false", "-c", "help.autocorrect=0"]).args(args); + cmd } /// The repo's own filter drivers and git-lfs extensions (local and worktree scope), blanked through env pairs, @@ -758,6 +767,44 @@ pub(crate) mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// A git without GIT_NO_LAZY_FETCH (before 2.45 and without the CVE-2024-32465 backport), and a repo that picks the transport itself. + #[test] + fn a_lazy_fetch_reaches_no_transport_whatever_the_repo_allows() { + if !git_available() { + return; + } + let mut fired = Vec::new(); + for case in ["ssh", "ext", "empty helper", "autocorrected helper"] { + let (dir, _, hit) = hit_repo(&format!("transport-{}", case.replace(' ', "-"))); + let blob = Command::new("git").arg("-C").arg(&dir).args(["rev-parse", ":README.md"]).output().unwrap(); + let blob = String::from_utf8_lossy(&blob.stdout).trim().to_string(); + let touch = format!("!touch '{}'", hit.display()); + let keys: Vec<(&str, String)> = match case { + "ssh" => vec![("remote.origin.url", "ssh://h/x".into()), ("protocol.ssh.allow", "always".into()), ("core.sshCommand", format!("touch '{}' #", hit.display()))], + "ext" => vec![("remote.origin.url", format!("ext::sh -c touch% {}", hit.display())), ("protocol.ext.allow", "always".into())], + // The helper "" runs as `git remote-`, which the repo's alias answers. + "empty helper" => vec![("remote.origin.url", "::x".into()), ("alias.remote-", touch)], + // The allowed helper "_" runs as `git remote-_`, which autocorrect would hand to the nearest alias. + _ => vec![("remote.origin.url", "x".into()), ("remote.origin.vcs", "_".into()), ("help.autocorrect", "-1".into()), ("alias.remote-a", touch)], + }; + run(&dir, &["config", "core.repositoryformatversion", "1"]); + run(&dir, &["config", "extensions.partialClone", "origin"]); + run(&dir, &["config", "remote.origin.promisor", "true"]); + for (k, v) in &keys { + run(&dir, &["config", k, v]); + } + std::fs::remove_file(dir.join(".git/objects").join(&blob[..2]).join(&blob[2..])).unwrap(); + let mut cmd = read_cmd(&dir, ["cat-file", "-p", &blob], &[]); + cmd.env_remove("GIT_NO_LAZY_FETCH"); + let _ = super::run(cmd); + if ran(&hit) { + fired.push(case); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + assert!(fired.is_empty(), "the repo's transport ran: {fired:?}"); + } + #[test] fn a_submodules_clean_filter_does_not_run() { if !git_available() { diff --git a/src/hook.rs b/src/hook.rs index 75da9e0..24ce4db 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -707,6 +707,7 @@ fn install_one(a: &Manifest, home: &Path) -> std::io::Result<()> { if let Some(dir) = config.parent() { std::fs::create_dir_all(dir)?; } + crate::isolation::make_agent_folders(a.data_paths(home), home); if a.shape() == Some(Shape::Plugin) { return std::fs::write(&config, plugin_for(a, &state_root(home))); } @@ -1861,6 +1862,18 @@ pub(crate) mod tests { out } + #[test] + fn install_makes_the_agent_folder_a_pane_could_move_in_whole() { + let home = std::env::temp_dir().join(format!("nerd-home-folders-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(&home).unwrap(); + let gemini = crate::agents::builtins().into_iter().find(|m| m.name == "gemini").unwrap(); + install_one(&gemini, &home).unwrap(); + assert!(home.join(".gemini/tmp").is_dir(), "Gemini's tmp holds its tools folder"); + assert!(!home.join(".gemini/projects.json").exists(), "only folders, only those"); + let _ = std::fs::remove_dir_all(&home); + } + fn ours(v: &Value) -> usize { v["hooks"].as_object().map_or(0, |h| h.values().filter_map(Value::as_array).flatten().filter(|e| is_ours(e)).count()) } diff --git a/src/isolation.rs b/src/isolation.rs index 7556ef8..1a89929 100644 --- a/src/isolation.rs +++ b/src/isolation.rs @@ -211,6 +211,21 @@ struct Places { } impl Places { + /// What a This-folder-only pane may not write: files, and folders with all they hold. + fn denied(&self) -> (Vec, Vec) { + let files = [self.exe.clone()].into_iter().chain(RUN_LATER.iter().map(|f| self.home.join(f))).chain(self.terminal_files.iter().cloned()).chain(self.hook_files.iter().cloned()).chain(self.agent.clone()); + let dirs = [self.root.clone(), self.tmp.join("podman")].into_iter().chain(RUN_LATER_DIRS.iter().map(|d| self.home.join(d))).chain(self.terminal_dirs.iter().cloned()).chain(self.hook_dirs.iter().cloned()); + (files.collect(), dirs.collect()) + } + + /// Agents' data folders that hold a denied place, made when missing and their folder is there: one moved in whole would bring what runs later. + fn make_agent_folders(&self) { + let (files, dirs) = self.denied(); + for a in self.data.iter().filter(|a| !a.to_string_lossy().contains('*') && files.iter().chain(&dirs).any(|d| d != *a && d.starts_with(a))) { + let _ = std::fs::create_dir(a); + } + } + fn here() -> Result { let home = crate::paths::home().unwrap_or_default(); if !home.is_absolute() || home == Path::new("/") { @@ -239,11 +254,15 @@ impl Places { /// Under HOME, run later by a shell, git, ssh, an editor, tmux, a package manager or toolchain from its cache, launchd or `defaults`, or ahead on PATH: /// never writable, even when the pane's folder is HOME itself. const RUN_LATER: &[&str] = &[".zshrc", ".zprofile", ".zshenv", ".zlogin", ".zlogout", ".bashrc", ".bash_profile", ".bash_login", ".bash_logout", ".profile", ".gitconfig", ".netrc", ".npmrc", ".cargo/config.toml", ".vimrc", ".tmux.conf", ".emacs", ".wezterm.lua"]; +// ceiling: a denylist, so a cache that runs code and is not named here stays writable; an allowlist of the agents' own caches under ~/Library/Caches is the way past. const RUN_LATER_DIRS: &[&str] = &[ ".config/git", ".config/fish", "Library/LaunchAgents", ".ssh", ".aws", ".gnupg", ".docker", ".kube", ".azure", ".config/gh", ".config/gcloud", ".cargo/bin", ".local/bin", ".vim", ".config/nvim", ".config/tmux", ".emacs.d", "Library/Preferences", ".pyenv/shims", ".rbenv/shims", ".nvm", ".bun/bin", "go/bin", ".volta/bin", ".deno/bin", ".asdf/shims", ".local/share/nvim", "Library/Caches/pnpm", "Library/Caches/uv", "Library/Caches/deno", "Library/Caches/go-build", "Library/Caches/ms-playwright", "Library/Caches/pypoetry", "Library/Caches/Yarn", "Library/Caches/node-gyp", "Library/Caches/pip", "Library/Caches/Cypress", "Library/Caches/org.swift.swiftpm", "Library/Caches/copilot", "go/pkg/mod", + // The system Python's bytecode, Homebrew's downloads and bootsnap, compiler caches, downloaded build tools. + "Library/Caches/com.apple.python", "Library/Caches/Homebrew", "Library/Caches/ccache", "Library/Caches/Mozilla.sccache", "Library/Caches/esbuild", + "Library/Caches/bazelisk", "Library/Caches/Coursier", // Agents' own: sourced before each command, ahead on PATH, downloaded tools, git repos the agent runs git in, tasks other sessions take up. ".claude/shell-snapshots", ".claude/session-env", ".codex/shell_snapshots", ".codex/tmp", ".codex/.tmp", ".gemini/tmp/bin", ".gemini/history", ".local/share/opencode/snapshot", ".claude/tasks", // Terminals' configs, which start programs and which `nerd --preset` edits. @@ -258,11 +277,27 @@ const UNREAD: &[&str] = &[".ssh", ".aws", ".gnupg", ".netrc", ".docker", ".kube" /// The pane's Seatbelt profile. An account pane passes `Everything` or `Account`: the account confines its files. pub fn profile(files: &Files, network: &Network, cwd: &Path, msock: &Path, port: Option) -> Result { - text(&Places::here()?, files, network, cwd, msock, port) + let p = Places::here()?; + if *files == Files::Folder { + p.make_agent_folders(); + } + text(&p, files, network, cwd, msock, port) +} + +/// At hook install: the agent's folders a This-folder-only pane could otherwise move in whole. +pub fn make_agent_folders(data: Vec, home: &Path) { + if let Ok(p) = Places::here() { + Places { data, home: home.to_path_buf(), ..p }.make_agent_folders(); + } } fn rules(kind: &str, ps: impl IntoIterator) -> Result { - ps.into_iter().map(|p| quote(&canon(&p)).map(|s| format!(" ({kind} {s})"))).collect() + raw_rules(kind, ps.into_iter().map(|p| canon(&p))) +} + +/// Paths as given: a link itself, not where it points. +fn raw_rules(kind: &str, ps: impl IntoIterator) -> Result { + ps.into_iter().map(|p| quote(&p).map(|s| format!(" ({kind} {s})"))).collect() } /// A data path as a rule: a subpath, or a regex when its last part has a `*` (`~/.copilot/session-store.db*`). @@ -302,26 +337,67 @@ fn text(p: &Places, files: &Files, network: &Network, cwd: &Path, msock: &Path, } else { String::new() }; - // rules() resolves a linked nerd (Homebrew's) to its target; the link is kept too. - // ceiling: the keg folder above the target stays renamable from a pane whose folder holds it; deny the keg's subpath if that matters. - let link = match (p.exe.parent(), p.exe.file_name()) { - (Some(d), Some(n)) if canon(d).join(n) != canon(&p.exe) => format!(" (literal {})", quote(&canon(d).join(n))?), - _ => String::new(), - }; - let literal = [p.exe.clone()].into_iter().chain(RUN_LATER.iter().map(|f| p.home.join(f))).chain(p.terminal_files.iter().cloned()).chain(p.hook_files.iter().cloned()); - let dirs = [root.clone()].into_iter().chain(RUN_LATER_DIRS.iter().map(|d| p.home.join(d))).chain(p.terminal_dirs.iter().cloned()).chain(p.hook_dirs.iter().cloned()); + let (literal, dirs) = p.denied(); + // rules() resolves a link (Homebrew's nerd, a linked cache or dotfile folder) to its target; the link itself is denied too. + let raw = |a: &Path| a.parent().zip(a.file_name()).map(|(up, n)| canon(up).join(n)); + let links: Vec = p.roots.iter().chain(&dirs).chain(&literal).filter_map(|d| raw(d).filter(|r| *r != canon(d))).collect(); // The door other accounts exec nerd from, Claude Code's message sockets, agents' memory (Claude, Qwen), Codex's databases shared by its sessions (queued prompts, remote control) and Qwen's workflow scripts a resume runs, which the owner's own sessions read. - let doors = format!( - " (regex #\"^/private/tmp/nerd-[0-9]+(/|$)\") (regex #\"^/private/tmp/cc-socks\") (regex #\"^{}/cc-socks\") (regex #\"^{esc}/\\.[^/]+/projects/[^/]+/memory(/|$)\") (regex #\"^{esc}/\\.codex/[^/]*\\.sqlite\") (regex #\"^{esc}/\\.qwen/projects/[^/]+/workflows(/|$)\")", - crate::plugins::regex_escape(&tmp.to_string_lossy()) - ); + let mut doors: Vec<(PathBuf, &[&str], &str)> = vec![ + ("/private/tmp".into(), &["nerd-[0-9]+"], "(/|$)"), + ("/private/tmp".into(), &["cc-socks"], ""), + (tmp.clone(), &["cc-socks"], ""), + (home.clone(), &["\\.[^/]+", "projects", "[^/]+", "memory"], "(/|$)"), + (home.clone(), &["\\.codex", "[^/]*\\.sqlite"], ""), + (home.clone(), &["\\.qwen", "projects", "[^/]+", "workflows"], "(/|$)"), + ]; + // A linked agent folder (Stow's ~/.claude) is allowed at its target, so the doors under HOME are denied there too. + let fits = |part: &str, name: &str| match part { + "[^/]+" => !name.is_empty(), + "\\.[^/]+" => name.len() > 1 && name.starts_with('.'), + _ => part == crate::plugins::regex_escape(name), + }; + for r in p.roots.iter().chain(&p.data) { + let Ok(rel) = r.strip_prefix(&p.home) else { continue }; + if canon(r) == home.join(rel) { + continue; + } + let names: Vec = rel.iter().map(|n| n.to_string_lossy().into_owned()).collect(); + for i in 3..6 { + let (_, parts, end) = doors[i]; + if names.len() < parts.len() && names.iter().zip(parts).all(|(n, part)| fits(part, n)) { + doors.push((canon(r), &parts[names.len()..], end)); + } + } + } + let re = |base: &Path, parts: &[&str], end: &str| format!(" (regex #\"^{}/{}{end}\")", crate::plugins::regex_escape(&base.to_string_lossy()), parts.join("/")); + // Seatbelt checks a rename against the moved path, not its folder: every folder above a denied place keeps its name and gets no link put in its place. + let mut above = std::collections::BTreeSet::new(); + for d in p.roots.iter().chain(&dirs).chain(&literal) { + above.extend(canon(d).ancestors().skip(1).map(Path::to_path_buf)); + for a in d.ancestors().skip(1) { + above.extend(canon(a).ancestors().map(Path::to_path_buf)); + above.extend(raw(a)); + } + } + above.extend(doors.iter().flat_map(|(base, ..)| canon(base).ancestors().map(Path::to_path_buf).collect::>())); + let door_parents: String = doors.iter().flat_map(|(base, parts, _)| (1..parts.len()).map(move |i| re(base, &parts[..i], "$"))).collect(); + // Agents make their own folders, so only a link is refused there; the named ones are made at hook install and before each pane starts (make_agent_folders). + // ceiling: a missing `~/./projects` or project folder in it can still be renamed in whole from a writable one, with memory or Qwen's workflow scripts inside: Seatbelt sees that as creating the folder; so can a named one deleted outside a pane, until the next pane starts. + // ceiling: only a place's own path and its first link are denied, so a chain whose middle link sits in a writable folder can be pointed elsewhere and a dangling denied link's future target stays writable; resolving each link in turn is the way past. + let data: Vec = p.data.iter().map(|d| canon(d)).collect(); + let (theirs, fixed): (Vec, Vec) = above.iter().cloned().partition(|a| data.iter().any(|d| a.starts_with(d))); out += &format!( - "(deny file-write*)\n(allow file-write*{} (regex #\"^/dev/\"))\n{dots}(deny file-write*{})\n(allow file-write*{})\n(deny file-write*{}{}{doors})\n(deny file-read-data{})\n", + "(deny file-write*)\n(allow file-write*{} (regex #\"^/dev/\"))\n{dots}(deny file-write*{})\n(allow file-write*{})\n(deny file-write*{}{}{})\n\ + (deny file-write-unlink{}{door_parents})\n(deny file-write-create{})\n(deny file-write-create (require-all (vnode-type SYMLINK) (require-any{}{door_parents})))\n(deny file-read-data{})\n", rules("subpath", writes)?, rules("subpath", p.roots.iter().cloned())?, data_rules(&p.data)?, rules("subpath", dirs)?, - rules("literal", literal)? + &link, + rules("literal", literal)? + &raw_rules("literal", links)?, + doors.iter().map(|(base, parts, end)| re(base, parts, end)).collect::(), + raw_rules("literal", above)?, + raw_rules("literal", fixed)?, + raw_rules("literal", theirs)?, rules("subpath", UNREAD.iter().map(|s| p.home.join(s)).chain([root.clone()]))?, ); } @@ -443,10 +519,8 @@ fn fail(why: impl std::fmt::Display) -> ! { /// The first data path that exists and cannot be written here. fn unwritable(data: &[PathBuf]) -> Option<&Path> { data.iter().map(PathBuf::as_path).find(|p| match std::fs::metadata(p) { - Ok(m) if m.is_dir() => { - let probe = p.join(format!(".nerd-write-check-{}", std::process::id())); - std::fs::OpenOptions::new().write(true).create_new(true).open(&probe).and_then(|_| std::fs::remove_file(&probe)).is_err() - } + // Asked, not tried: the profile may refuse removing a probe file made here, which would then stay. + Ok(m) if m.is_dir() => nix::unistd::access(*p, nix::unistd::AccessFlags::W_OK).is_err() || std::fs::canonicalize(p).is_ok_and(|c| !crate::sandbox::may_write(&c)), Ok(_) => std::fs::OpenOptions::new().append(true).open(p).is_err(), Err(_) => false, }) @@ -565,7 +639,7 @@ mod tests { let real = std::fs::canonicalize(env!("CARGO_MANIFEST_DIR")).unwrap(); assert!(!["/private/tmp", "/private/var/folders"].iter().any(|t| real.starts_with(t)), "the isolation tests need the checkout outside the temp folders a pane may write, not in {}", real.display()); let _ = std::fs::remove_dir_all(&d); - for sub in ["home/.claude/hooks", "home/.claude/projects", "home/.claude/skills", "home/.codex", "home/.copilot", "home/.gemini/tmp", "home/.qwen/projects", "home/.ssh", "home/Library/LaunchAgents", "home/.config/git", "state", "work"] { + for sub in ["home/.claude/hooks", "home/.claude/projects", "home/.claude/skills", "home/.codex", "home/.copilot", "home/.gemini/tmp", "home/.qwen/projects", "home/.ssh", "home/Library/Caches", "home/Library/LaunchAgents", "home/.config/git", "state", "work"] { std::fs::create_dir_all(d.join(sub)).unwrap(); } std::fs::write(d.join("home/.ssh/id"), "secret").unwrap(); @@ -639,18 +713,34 @@ mod tests { }; let m = Path::new("/Users/u/Library/Application Support/nerd/nerd.msock"); let t = |f: Files, n: Network, port| text(&p, &f, &n, d, m, port).unwrap(); - let folder = "(deny file-write*)\n(allow file-write* (subpath \"/Users/u/x\") (subpath \"/private/var/folders/ab/T\") (subpath \"/private/tmp\") (subpath \"/private/var/folders\") (subpath \"/Users/u/Library/Caches\") (regex #\"^/dev/\"))\n\ + let above = ["/", "/Users", "/Users/u", "/Users/u/.1p", "/Users/u/.asdf", "/Users/u/.bun", "/Users/u/.cargo", "/Users/u/.claude", "/Users/u/.codex", "/Users/u/.config", "/Users/u/.deno", "/Users/u/.gemini"] + .into_iter() + .chain(["/Users/u/.gemini/tmp", "/Users/u/.local", "/Users/u/.local/bin", "/Users/u/.local/share", "/Users/u/.local/share/opencode", "/Users/u/.pyenv", "/Users/u/.rbenv", "/Users/u/.volta", "/Users/u/Library"]) + .chain(["/Users/u/Library/Application Support", "/Users/u/Library/Application Support/iTerm2", "/Users/u/Library/Caches", "/Users/u/go", "/Users/u/go/pkg"]) + .chain(["/private", "/private/tmp", "/private/var", "/private/var/folders", "/private/var/folders/ab", "/private/var/folders/ab/T"]) + .map(|a| format!("(literal \"{a}\")")) + .collect::>() + .join(" "); + let parents = ["\\.[^/]+", "\\.[^/]+/projects", "\\.[^/]+/projects/[^/]+", "\\.codex", "\\.qwen", "\\.qwen/projects", "\\.qwen/projects/[^/]+"].map(|r| format!("(regex #\"^/Users/u/{r}$\")")).join(" "); + let folder = &format!( + "(deny file-write*)\n(allow file-write* (subpath \"/Users/u/x\") (subpath \"/private/var/folders/ab/T\") (subpath \"/private/tmp\") (subpath \"/private/var/folders\") (subpath \"/Users/u/Library/Caches\") (regex #\"^/dev/\"))\n\ (deny file-write* (subpath \"/Users/u/.claude.json\") (subpath \"/Users/u/.claude\"))\n\ (allow file-write* (subpath \"/Users/u/.claude/projects\") (regex #\"^/Users/u/\\.codex/[^/]*\\.sqlite[^/]*$\"))\n\ - (deny file-write* (subpath \"/Users/u/Library/Application Support/nerd\") (subpath \"/Users/u/.config/git\") (subpath \"/Users/u/.config/fish\") (subpath \"/Users/u/Library/LaunchAgents\") (subpath \"/Users/u/.ssh\") (subpath \"/Users/u/.aws\") (subpath \"/Users/u/.gnupg\") (subpath \"/Users/u/.docker\") (subpath \"/Users/u/.kube\") (subpath \"/Users/u/.azure\") (subpath \"/Users/u/.config/gh\") (subpath \"/Users/u/.config/gcloud\") (subpath \"/Users/u/.cargo/bin\") (subpath \"/Users/u/.local/bin\") \ + (deny file-write* (subpath \"/Users/u/Library/Application Support/nerd\") (subpath \"/private/var/folders/ab/T/podman\") (subpath \"/Users/u/.config/git\") (subpath \"/Users/u/.config/fish\") (subpath \"/Users/u/Library/LaunchAgents\") (subpath \"/Users/u/.ssh\") (subpath \"/Users/u/.aws\") (subpath \"/Users/u/.gnupg\") (subpath \"/Users/u/.docker\") (subpath \"/Users/u/.kube\") (subpath \"/Users/u/.azure\") (subpath \"/Users/u/.config/gh\") (subpath \"/Users/u/.config/gcloud\") (subpath \"/Users/u/.cargo/bin\") (subpath \"/Users/u/.local/bin\") \ (subpath \"/Users/u/.vim\") (subpath \"/Users/u/.config/nvim\") (subpath \"/Users/u/.config/tmux\") (subpath \"/Users/u/.emacs.d\") (subpath \"/Users/u/Library/Preferences\") (subpath \"/Users/u/.pyenv/shims\") (subpath \"/Users/u/.rbenv/shims\") (subpath \"/Users/u/.nvm\") (subpath \"/Users/u/.bun/bin\") (subpath \"/Users/u/go/bin\") (subpath \"/Users/u/.volta/bin\") (subpath \"/Users/u/.deno/bin\") (subpath \"/Users/u/.asdf/shims\") (subpath \"/Users/u/.local/share/nvim\") \ (subpath \"/Users/u/Library/Caches/pnpm\") (subpath \"/Users/u/Library/Caches/uv\") (subpath \"/Users/u/Library/Caches/deno\") (subpath \"/Users/u/Library/Caches/go-build\") (subpath \"/Users/u/Library/Caches/ms-playwright\") (subpath \"/Users/u/Library/Caches/pypoetry\") (subpath \"/Users/u/Library/Caches/Yarn\") (subpath \"/Users/u/Library/Caches/node-gyp\") \ (subpath \"/Users/u/Library/Caches/pip\") (subpath \"/Users/u/Library/Caches/Cypress\") (subpath \"/Users/u/Library/Caches/org.swift.swiftpm\") (subpath \"/Users/u/Library/Caches/copilot\") (subpath \"/Users/u/go/pkg/mod\") \ + (subpath \"/Users/u/Library/Caches/com.apple.python\") (subpath \"/Users/u/Library/Caches/Homebrew\") (subpath \"/Users/u/Library/Caches/ccache\") (subpath \"/Users/u/Library/Caches/Mozilla.sccache\") (subpath \"/Users/u/Library/Caches/esbuild\") \ + (subpath \"/Users/u/Library/Caches/bazelisk\") (subpath \"/Users/u/Library/Caches/Coursier\") \ (subpath \"/Users/u/.claude/shell-snapshots\") (subpath \"/Users/u/.claude/session-env\") (subpath \"/Users/u/.codex/shell_snapshots\") (subpath \"/Users/u/.codex/tmp\") (subpath \"/Users/u/.codex/.tmp\") (subpath \"/Users/u/.gemini/tmp/bin\") (subpath \"/Users/u/.gemini/history\") (subpath \"/Users/u/.local/share/opencode/snapshot\") (subpath \"/Users/u/.claude/tasks\") \ (subpath \"/Users/u/.config/ghostty\") (subpath \"/Users/u/Library/Application Support/com.mitchellh.ghostty\") (subpath \"/Users/u/.config/kitty\") (subpath \"/Users/u/.config/wezterm\") (subpath \"/Users/u/Library/Application Support/iTerm2/DynamicProfiles\") (subpath \"/Users/u/.claude/hooks\") \ - (literal \"/Users/u/.local/bin/nerd\") (literal \"/Users/u/.zshrc\") (literal \"/Users/u/.zprofile\") (literal \"/Users/u/.zshenv\") (literal \"/Users/u/.zlogin\") (literal \"/Users/u/.zlogout\") (literal \"/Users/u/.bashrc\") (literal \"/Users/u/.bash_profile\") (literal \"/Users/u/.bash_login\") (literal \"/Users/u/.bash_logout\") (literal \"/Users/u/.profile\") (literal \"/Users/u/.gitconfig\") (literal \"/Users/u/.netrc\") (literal \"/Users/u/.npmrc\") (literal \"/Users/u/.cargo/config.toml\") (literal \"/Users/u/.vimrc\") (literal \"/Users/u/.tmux.conf\") (literal \"/Users/u/.emacs\") (literal \"/Users/u/.wezterm.lua\") (literal \"/Users/u/.claude/settings.json\") \ + (literal \"/Users/u/.local/bin/nerd\") (literal \"/Users/u/.zshrc\") (literal \"/Users/u/.zprofile\") (literal \"/Users/u/.zshenv\") (literal \"/Users/u/.zlogin\") (literal \"/Users/u/.zlogout\") (literal \"/Users/u/.bashrc\") (literal \"/Users/u/.bash_profile\") (literal \"/Users/u/.bash_login\") (literal \"/Users/u/.bash_logout\") (literal \"/Users/u/.profile\") (literal \"/Users/u/.gitconfig\") (literal \"/Users/u/.netrc\") (literal \"/Users/u/.npmrc\") (literal \"/Users/u/.cargo/config.toml\") (literal \"/Users/u/.vimrc\") (literal \"/Users/u/.tmux.conf\") (literal \"/Users/u/.emacs\") (literal \"/Users/u/.wezterm.lua\") (literal \"/Users/u/.claude/settings.json\") (literal \"/Users/u/.1p/agent.sock\") \ (regex #\"^/private/tmp/nerd-[0-9]+(/|$)\") (regex #\"^/private/tmp/cc-socks\") (regex #\"^/private/var/folders/ab/T/cc-socks\") (regex #\"^/Users/u/\\.[^/]+/projects/[^/]+/memory(/|$)\") (regex #\"^/Users/u/\\.codex/[^/]*\\.sqlite\") (regex #\"^/Users/u/\\.qwen/projects/[^/]+/workflows(/|$)\"))\n\ - (deny file-read-data (subpath \"/Users/u/.ssh\") (subpath \"/Users/u/.aws\") (subpath \"/Users/u/.gnupg\") (subpath \"/Users/u/.netrc\") (subpath \"/Users/u/.docker\") (subpath \"/Users/u/.kube\") (subpath \"/Users/u/.azure\") (subpath \"/Users/u/.config/gh\") (subpath \"/Users/u/.config/gcloud\") (subpath \"/Users/u/Library/Mail\") (subpath \"/Users/u/Library/Messages\") (subpath \"/Users/u/Library/Cookies\") (subpath \"/Users/u/Library/Application Support/nerd\"))\n"; + (deny file-write-unlink {above} {parents})\n\ + (deny file-write-create {above})\n\ + (deny file-write-create (require-all (vnode-type SYMLINK) (require-any {parents})))\n\ + (deny file-read-data (subpath \"/Users/u/.ssh\") (subpath \"/Users/u/.aws\") (subpath \"/Users/u/.gnupg\") (subpath \"/Users/u/.netrc\") (subpath \"/Users/u/.docker\") (subpath \"/Users/u/.kube\") (subpath \"/Users/u/.azure\") (subpath \"/Users/u/.config/gh\") (subpath \"/Users/u/.config/gcloud\") (subpath \"/Users/u/Library/Mail\") (subpath \"/Users/u/Library/Messages\") (subpath \"/Users/u/Library/Cookies\") (subpath \"/Users/u/Library/Application Support/nerd\"))\n" + ); let head = "(version 1)\n(allow default)\n"; let msock = "(allow network-outbound (literal \"/Users/u/Library/Application Support/nerd/nerd.msock\"))\n"; let closed = |local: &str| format!("(deny network*)\n(allow network-bind network-inbound (local unix-socket))\n{msock}(allow network-outbound (remote ip \"localhost:{local}\"))\n"); @@ -933,7 +1023,11 @@ mod tests { assert_eq!(ok, 0, "its own folder"); assert_eq!(try_("home/.claude/projects/session"), Some(0), "an agent's session folder"); assert_ne!(try_("home/.claude/session"), Some(0), "the rest of its own folder"); - for no in ["state/config.json", "home/.claude/settings.json", "home/.claude/hooks/x", "home/.zshrc", "home/x"] { + // Where previews are converted: a pane that could write there would swap what sips wrote. + for dir in ["state/preview", "state/instances/2/preview"] { + std::fs::create_dir_all(d.join(dir)).unwrap(); + } + for no in ["state/config.json", "state/preview/0.png", "state/instances/2/preview/0.png", "home/.claude/settings.json", "home/.claude/hooks/x", "home/.zshrc", "home/x"] { assert_ne!(try_(no), Some(0), "{no}"); assert!(!d.join(no).exists(), "{no}"); } @@ -959,6 +1053,58 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + #[test] + fn folder_only_cannot_replace_a_linked_denied_place_a_linked_folder_above_one_nor_move_podmans_folder() { + let d = scratch("links"); + let home = d.join("home"); + for (target, link) in [("ext/pnpm", "home/Library/Caches/pnpm"), ("home/src/go/bin", "")] { + std::fs::create_dir_all(d.join(target)).unwrap(); + if !link.is_empty() { + std::os::unix::fs::symlink(d.join(target), d.join(link)).unwrap(); + } + } + std::os::unix::fs::symlink(home.join("src/go"), home.join("go")).unwrap(); + std::fs::create_dir_all(d.join("tmp/podman")).unwrap(); + for (cwd, what, script) in [ + (d.join("work"), "a linked denied cache", "rm Library/Caches/pnpm && mkdir -p Library/Caches/pnpm && echo x > Library/Caches/pnpm/x"), + (home.clone(), "a linked folder above a denied one", "rm go && mkdir -p go/bin && echo x > go/bin/x"), + (d.join("work"), "podman's folder in the temp folder", "mv ../tmp/podman ../tmp/moved"), + ] { + let profile = text(&places(&d), &Files::Folder, &Network::None, &cwd, Path::new("/tmp/none.msock"), None).unwrap(); + let Some(rc) = sandboxed(&profile, &format!("cd '{}' && {script}", home.display())) else { return }; + assert_ne!(rc, 0, "{what}"); + } + assert!(std::fs::symlink_metadata(home.join("Library/Caches/pnpm")).unwrap().is_symlink() && std::fs::symlink_metadata(home.join("go")).unwrap().is_symlink()); + assert!(d.join("tmp/podman").is_dir()); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn folder_only_keeps_memory_and_workflows_in_a_linked_agent_folder() { + for (name, linked) in [("stow", ".claude"), ("sync", ".claude/projects")] { + let d = scratch(name); + let home = d.join("home"); + for (dir, target) in [(linked, "dotfiles/claude"), (".qwen", "dotfiles/qwen")] { + std::fs::remove_dir_all(home.join(dir)).unwrap(); + std::fs::create_dir_all(d.join(target).join(if dir == ".claude" || dir == ".qwen" { "projects/p" } else { "p" })).unwrap(); + std::os::unix::fs::symlink(d.join(target), home.join(dir)).unwrap(); + } + let profile = text(&places(&d), &Files::Folder, &Network::None, &d.join("work"), Path::new("/tmp/none.msock"), None).unwrap(); + let run = |script: String| sandboxed(&profile, &script); + let Some(rc) = run(format!("echo s > '{}'", home.join(".claude/projects/p/s.jsonl").display())) else { return }; + assert_eq!(rc, 0, "a session file, {linked} linked"); + for (what, script) in [ + ("memory", format!("mkdir -p '{0}' && echo x > '{0}/M'", home.join(".claude/projects/p/memory").display())), + ("Qwen's workflows", format!("mkdir -p '{0}' && echo x > '{0}/x.js'", home.join(".qwen/projects/p/workflows").display())), + ("the project folder", format!("mv '{}' '{}'", home.join(".claude/projects/p").display(), d.join("work/moved").display())), + ] { + assert_ne!(run(script), Some(0), "{what}, {linked} linked"); + } + assert!(home.join(".claude/projects/p").is_dir()); + let _ = std::fs::remove_dir_all(&d); + } + } + #[test] fn folder_only_keeps_agents_settings_and_what_runs_later_even_when_the_folder_is_home() { let d = scratch("roots"); @@ -1024,6 +1170,13 @@ mod tests { "Library/Caches/Cypress/x/Cypress.app/y", "Library/Caches/org.swift.swiftpm/repositories/x/HEAD", "Library/Caches/copilot/pkg/x/index.js", + "Library/Caches/com.apple.python/usr/lib/python3.9/__pycache__/os.cpython-39.pyc", + "Library/Caches/Homebrew/bootsnap/x", + "Library/Caches/ccache/a/b/x.o", + "Library/Caches/Mozilla.sccache/a/b/x", + "Library/Caches/esbuild/bin/esbuild", + "Library/Caches/bazelisk/downloads/x/bin/bazel", + "Library/Caches/Coursier/v1/https/x.jar", ".claude/projects/p/memory/MEMORY.md", ".claude/projects/p/memory", "Library/Caches/PNPM/store/x", @@ -1053,6 +1206,11 @@ mod tests { assert_ne!(write(Path::new("/tmp/cc-socks-nt-s5/x")), Some(0), "Claude Code's sockets, cwd {cwd:?}"); assert_eq!(write(&std::env::temp_dir().join(format!("nt-s5-{}", std::process::id()))), Some(0), "the temp folder stays writable"); if cwd == home { + std::fs::create_dir_all(home.join("staged/bin")).unwrap(); + let mv = |from: &str, to: &str| sandboxed(&profile, &format!("/bin/mv '{}' '{}'", home.join(from).display(), home.join(to).display())); + assert_ne!(mv("staged", "go"), Some(0), "a folder put in whole where a missing one above a denied one goes"); + assert_ne!(mv("Library/Caches", "moved"), Some(0), "a folder above a denied one keeps its name"); + assert!(!home.join("go").exists() && home.join("Library/Caches").is_dir()); assert_eq!(write(&home.join("notes.txt")), Some(0), "the rest of a home folder chosen as the pane's"); assert_eq!(write(&home.join("Documents/x.txt")), Some(0), "a folder of the owner's files"); for no in [ diff --git a/src/mail.rs b/src/mail.rs index 2725578..fccc6e7 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -306,7 +306,7 @@ pub fn clean(text: &str) -> String { /// Format characters and line separators: invisible on screen, yet read by an agent. ZWNJ and ZWJ stay for scripts and emoji. // ceiling: a fixed list of Unicode's format characters; a general-category table would follow new Unicode versions. -fn hidden(c: char) -> bool { +pub(crate) fn hidden(c: char) -> bool { matches!(c, '\u{AD}' | '\u{600}'..='\u{605}' | '\u{61C}' | '\u{6DD}' | '\u{70F}' | '\u{180E}' | '\u{200B}' | '\u{200E}'..='\u{200F}' | '\u{2028}'..='\u{202E}' | '\u{2060}'..='\u{206F}' | '\u{FEFF}' | '\u{FFF9}'..='\u{FFFB}' | '\u{E0000}'..='\u{E007F}') } diff --git a/src/main.rs b/src/main.rs index 0602afb..23999ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -237,6 +237,7 @@ fn run() -> std::io::Result<()> { } /// Format: `nerd 0.2.0 (f4db629, 2026-09-16 15:03)`, `+` after the commit if the tree was dirty at build time. +/// install.sh refuses an archive whose nerd's second word here is not the archive's version. pub fn version_line() -> String { format!("nerd {} ({})", env!("CARGO_PKG_VERSION"), env!("NERD_BUILD")) } @@ -321,7 +322,12 @@ pub fn body_area(size: ratatui::layout::Size) -> ratatui::layout::Rect { #[cfg(test)] mod tests { - use super::{from_window, help, resume_name}; + use super::{from_window, help, resume_name, version_line}; + + #[test] + fn the_second_word_of_the_version_line_is_the_version_install_sh_checks() { + assert_eq!(version_line().split(' ').nth(1), Some(env!("CARGO_PKG_VERSION"))); + } #[test] fn the_help_says_send_messages_another_agent() { diff --git a/src/paths.rs b/src/paths.rs index 35bee40..c46ebe6 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -25,7 +25,7 @@ pub fn home() -> Option { if let Some(h) = TEST_HOME.with(|h| h.borrow().clone()) { return Some(h); } - let home = std::env::var_os("HOME").map(PathBuf::from); + let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(PathBuf::from); #[cfg(test)] let home = Some(home.filter(|h| h.starts_with(std::env::temp_dir())).unwrap_or_else(|| std::env::temp_dir().join(format!("nerd-test-home-{}", std::process::id())))); home @@ -72,7 +72,7 @@ pub fn state_root() -> PathBuf { if let Some(p) = std::env::var_os("NERD_STATE_DIR") { return PathBuf::from(p); } - // launchd can start us with no HOME; fall back to /tmp instead of panicking. + // launchd can start us with no HOME; fall back to the per-user temp folder (Darwin's, not /tmp) instead of panicking. nerd_dir(&home().unwrap_or_else(std::env::temp_dir)) } diff --git a/src/preview.rs b/src/preview.rs index 0158a10..558454f 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -532,7 +532,8 @@ pub fn to_png(path: &Path) -> Option>> { /// A PDF's first page on white, as a JPEG of the best quality: a page is paper, and sips draws a PDF on /// nothing, which a dark preview would show as dark on dark. None for any other file; the caller removes it. -fn paper(path: &Path) -> Option { +/// With the size the page was drawn at, read from the JPEG: a `/MediaBox` read before the real one can understate it. +fn paper(path: &Path) -> Option<(PathBuf, Option<(u32, u32)>)> { let mut head = Vec::new(); let mut f = std::fs::File::open(path).ok()?.take(IMAGE_LIMIT); f.by_ref().take(5).read_to_end(&mut head).ok()?; @@ -542,30 +543,32 @@ fn paper(path: &Path) -> Option { let _ = f.read_to_end(&mut head); // A page whose size could not be read, or a large one, is drawn no larger than this; -Z upscales too, so not a small one. let cap: &[&str] = if pdf_page(&head).is_none_or(|(w, h)| w.max(h) > 2048) { &["-Z", "2048"] } else { &[] }; - let tmp = temp_path("jpg"); - // A failed run leaves no file, and the conversion from it says so. - let _ = crate::spawn::cmd("/usr/bin/sips") - .args(cap) - .args(["-s", "format", "jpeg", "-s", "formatOptions", "best"]) - .arg(path) - .arg("--out") - .arg(&tmp) - .stdin(std::process::Stdio::null()) - .output(); - Some(tmp) + let tmp = temp_path("jpg").ok()?; + let sips = |args: &[&str], from: &Path| { + // A failed run leaves no file, and the conversion from it says so. + let _ = crate::spawn::cmd("/usr/bin/sips").args(args).args(["-s", "format", "jpeg", "-s", "formatOptions", "best"]).arg(from).arg("--out").arg(&tmp).stdin(std::process::Stdio::null()).output(); + }; + sips(cap, path); + let drawn = || read_capped(&tmp, 1 << 20, false).ok().and_then(|b| image_size(&b, ImageFormat::Jpeg)); + let mut dims = drawn(); + if dims.is_none_or(|(w, h)| w.max(h) > 2048) { + sips(&["-Z", "2048"], &tmp); + dims = drawn(); + } + Some((tmp, dims)) } fn convert_to_png(path: &Path) -> Result, String> { let page = paper(path); - let made = convert_to_png_from(page.as_deref().unwrap_or(path)); - if let Some(p) = page { + let made = convert_to_png_from(page.as_ref().map_or(path, |(p, _)| p.as_path())); + if let Some((p, _)) = page { let _ = std::fs::remove_file(p); } made } fn convert_to_png_from(path: &Path) -> Result, String> { - let tmp = temp_path("png"); + let tmp = temp_path("png")?; let out = crate::spawn::cmd("/usr/bin/sips") .args(["-s", "format", "png"]) .arg(path) @@ -575,7 +578,7 @@ fn convert_to_png_from(path: &Path) -> Result, String> { .output() .map_err(|e| format!("sips did not run: {e}"))?; let result = if out.status.success() { - match std::fs::read(&tmp) { + match read_capped(&tmp, MAX_PIXELS as usize * 4 + (1 << 20), true) { Ok(b) if png_size(&b).is_some() => Ok(b), Ok(b) => Err(format!("sips wrote {} bytes that are not a PNG", b.len())), Err(e) => Err(format!("sips reported success but {}: {e}", tmp.display())), @@ -592,25 +595,71 @@ fn convert_to_png_from(path: &Path) -> Result, String> { result } -/// Unique per call, in the system temp dir: tests swap and delete the state dir while others convert. -fn temp_path(ext: &str) -> PathBuf { +/// Unique per call, in this instance's own folder under the state root: a pane isolated to its folder can write +/// the system temp folders, and would swap what sips writes for a link, a FIFO or another picture. +fn temp_path(ext: &str) -> Result { static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - std::env::temp_dir().join(format!("nerd-preview-{}-{n}.{ext}", std::process::id())) + Ok(temp_dir()?.join(format!("{n}.{ext}"))) +} + +/// Made when missing, refused when it is a link, another user's or open to others; checked on every call. +fn temp_dir() -> Result { + // Tests swap and delete the state dir while others convert, so they get one folder per process. + #[cfg(test)] + let dir = std::env::temp_dir().join(format!("nerd-preview-test-{}", std::process::id())); + #[cfg(not(test))] + let dir = { + static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); + DIR.get_or_init(|| crate::session::state_dir().join("preview")).clone() + }; + own_dir(dir) +} + +fn own_dir(dir: PathBuf) -> Result { + use std::os::unix::fs::{DirBuilderExt as _, MetadataExt as _}; + if std::fs::symlink_metadata(&dir).is_err() { + let _ = std::fs::DirBuilder::new().recursive(true).mode(0o700).create(&dir); + } + let m = std::fs::symlink_metadata(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + // SAFETY: geteuid has no preconditions. + let me = unsafe { nix::libc::geteuid() }; + if !m.is_dir() || m.uid() != me || m.mode() & 0o777 != 0o700 { + return Err(format!("{} is not a folder of this user's that only it can open", dir.display())); + } + Ok(dir) +} + +/// What sips wrote, refused rather than cut when it is not a plain file of at most `cap` bytes; or, `whole` false, its first `cap` bytes. +fn read_capped(path: &Path, cap: usize, whole: bool) -> Result, String> { + use std::os::unix::fs::OpenOptionsExt as _; + let f = std::fs::OpenOptions::new().read(true).custom_flags(nix::libc::O_NOFOLLOW | nix::libc::O_NONBLOCK).open(path).map_err(|e| e.to_string())?; + let m = f.metadata().map_err(|e| e.to_string())?; + if !m.is_file() { + return Err("not a plain file".into()); + } + if whole && m.len() > cap as u64 { + return Err(format!("{} bytes, more than a preview takes", m.len())); + } + let mut b = Vec::new(); + f.take(cap as u64).read_to_end(&mut b).map_err(|e| e.to_string())?; + Ok(b) } /// Off the loop, like `to_png`; unknown dimensions are scaled down too. pub fn to_pixels(path: &Path, dims: Option<(u32, u32)>) -> Option> { let page = paper(path); - let px = pixels_from(page.as_deref().unwrap_or(path), dims); - if let Some(p) = page { + // A page is drawn at the size sips gave it, not the size its PDF claims. + let dims = page.as_ref().map_or(dims, |(_, d)| *d); + let px = pixels_from(page.as_ref().map_or(path, |(p, _)| p.as_path()), dims); + if let Some((p, _)) = page { let _ = std::fs::remove_file(p); } px } fn pixels_from(path: &Path, dims: Option<(u32, u32)>) -> Option> { - let tmp = temp_path("bmp"); + let tmp = temp_path("bmp").ok()?; let mut cmd = crate::spawn::cmd("/usr/bin/sips"); // Without a colour profile sips writes palette PNGs as headerless raw pixels, and gray+alpha as a format we don't read. cmd.args(["-s", "format", "bmp", "-m", "/System/Library/ColorSync/Profiles/sRGB Profile.icc"]); @@ -625,7 +674,8 @@ fn pixels_from(path: &Path, dims: Option<(u32, u32)>) -> Option> { .stdin(std::process::Stdio::null()) .output() .is_ok_and(|o| o.status.success()); - let px = if ok { std::fs::read(&tmp).ok().and_then(|b| parse_bmp(&b)).map(Arc::new) } else { None }; + let bmp = (BLOCK_PIXELS as usize).pow(2) * 4 + (1 << 16); + let px = if ok { read_capped(&tmp, bmp, true).ok().and_then(|b| parse_bmp(&b)).map(Arc::new) } else { None }; let _ = std::fs::remove_file(&tmp); px } @@ -1727,6 +1777,11 @@ mod tests { /// A PDF of `n` pages, `w`×`h` points, each with its number on it, uncompressed. fn pdf(n: usize, w: u32, h: u32) -> Vec { + pdf_after("", n, w, h) + } + + /// `pdf`, with `head` (comment lines) right after the header. + fn pdf_after(head: &str, n: usize, w: u32, h: u32) -> Vec { let kids: Vec = (0..n).map(|i| format!("{} 0 R", 3 + 2 * i)).collect(); let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), format!("<< /Type /Pages /Kids [{}] /Count {n} >>", kids.join(" "))]; for i in 0..n { @@ -1734,7 +1789,7 @@ mod tests { objs.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Contents {} 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> >>", 4 + 2 * i)); objs.push(format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())); } - let mut b = b"%PDF-1.4\n".to_vec(); + let mut b = format!("%PDF-1.4\n{head}").into_bytes(); let mut at = Vec::new(); for (i, o) in objs.iter().enumerate() { at.push(b.len()); @@ -1782,6 +1837,69 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + #[test] + fn a_page_is_drawn_at_the_size_sips_gave_it_not_the_size_its_pdf_claims() { + let d = std::env::temp_dir().join(format!("nerd-pdf-decoy-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let doc = d.join("decoy.pdf"); + std::fs::write(&doc, pdf_after("%/MediaBox [0 0 100 100]\n", 1, 3000, 3000)).unwrap(); + assert!(matches!(Preview::load(&doc).kind, Kind::Image { dims: Some((100, 100)), .. }), "the decoy is what the header read sees"); + let png = to_png(&doc).expect("drawn"); + assert!(png_size(&png).is_some_and(|(w, h)| w.max(h) <= 2048), "{:?}", png_size(&png)); + let px = to_pixels(&doc, Some((100, 100))).expect("blocks"); + assert!(px.w.max(px.h) <= BLOCK_PIXELS as usize, "{}x{}", px.w, px.h); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_large_honest_picture_is_converted_whole() { + let d = std::env::temp_dir().join(format!("nerd-big-jpeg-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let big = d.join("big.jpg"); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/preview/photo.jpg"); + let made = std::process::Command::new("/usr/bin/sips").args(["-z", "3000", "3000"]).arg(&fixture).arg("--out").arg(&big).output().unwrap(); + assert!(made.status.success()); + let png = to_png(&big).unwrap_or_else(|| panic!("{:?}", convert_to_png(&big).err())); + assert_eq!(png_size(&png), Some((3000, 3000))); + assert!(png.ends_with(&[0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82]), "the whole file, up to its IEND chunk"); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn what_sips_wrote_is_read_only_from_a_plain_file_that_fits() { + let d = std::env::temp_dir().join(format!("nerd-read-capped-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(d.join("f"), vec![1u8; 100]).unwrap(); + assert_eq!(read_capped(&d.join("f"), 100, true).map(|b| b.len()), Ok(100)); + assert!(read_capped(&d.join("f"), 99, true).is_err(), "too big is refused, not cut"); + assert_eq!(read_capped(&d.join("f"), 10, false).map(|b| b.len()), Ok(10), "a header is the first bytes"); + std::os::unix::fs::symlink("/dev/zero", d.join("zero")).unwrap(); + assert!(read_capped(&d.join("zero"), 10, false).is_err(), "a link"); + nix::unistd::mkfifo(&d.join("fifo"), nix::sys::stat::Mode::S_IRWXU).unwrap(); + let fifo = d.join("fifo"); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(read_capped(&fifo, 10, false).is_err()).unwrap()); + assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(5)), Ok(true), "a FIFO, at once"); + std::fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn the_conversion_folder_is_this_users_alone() { + use std::os::unix::fs::PermissionsExt as _; + let dir = temp_dir().unwrap(); + assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700); + let d = std::env::temp_dir().join(format!("nerd-own-dir-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(d.join("real")).unwrap(); + std::os::unix::fs::symlink(d.join("real"), d.join("link")).unwrap(); + assert!(own_dir(d.join("link")).is_err(), "a link in its place"); + std::fs::set_permissions(d.join("real"), std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(own_dir(d.join("real")).is_err(), "open to others"); + assert!(own_dir(d.join("new")).is_ok(), "made when missing"); + std::fs::remove_dir_all(&d).unwrap(); + } + #[test] fn the_preview_fixtures_show_what_they_say() { let at = |f: &str| Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/preview").join(f); diff --git a/src/previews.rs b/src/previews.rs index f802bfa..8dbb2c7 100644 --- a/src/previews.rs +++ b/src/previews.rs @@ -35,14 +35,14 @@ pub fn kind(ext: &str) -> &'static str { pub fn answer(req: u64, path: &Path, cols: u16) -> Option { let ext = extension(path)?; let (text, cut) = read(path)?; - let lines = match ext.as_str() { - "csv" => table(&records(&text, cut)), - "json" if !cut => json(&text)?, + let (lines, short) = match ext.as_str() { + "csv" => (table(&records(&text, cut)), false), + "json" if !cut => (json(&text)?, false), "md" | "markdown" => markdown(&text, cols), _ => return None, }; let (lines, capped) = finish(lines); - Some(PluginMsg::Preview { req, lines, cut: cut || capped, modes: vec![], mode: String::new() }) + Some(PluginMsg::Preview { req, lines, cut: cut || short || capped, modes: vec![], mode: String::new() }) } /// The file's text, and whether it was longer than read; `None` for what is not a readable text file. @@ -131,8 +131,10 @@ fn records(text: &str, cut: bool) -> Vec> { } /// Columns aligned to their widest cell, the first row as the header, a line break in a cell shown as `↵`. +/// Only what can show is built: a column past these starts beyond LINE_CELLS, a row past PREVIEW_LINES is cut by `finish`. fn table(rows: &[Vec]) -> Lines { - let shown: Vec> = rows.iter().map(|r| r.iter().map(|f| fit(&clean(&f.replace("\r\n", "\n").replace('\n', "\u{21b5}")), CELL)).collect()).collect(); + const COLUMNS: usize = LINE_CELLS / 3 + 2; + let shown: Vec> = rows.iter().map(|r| r.iter().take(COLUMNS).map(|f| fit(&clean(&f.replace("\r\n", "\n").replace('\n', "\u{21b5}")), CELL)).collect()).collect(); let n = shown.iter().map(Vec::len).max().unwrap_or(0); let widths: Vec = (0..n).map(|j| shown.iter().filter_map(|r| r.get(j)).map(|c| cells(c)).max().unwrap_or(0)).collect(); let row = |r: &[String], role: &'static str| { @@ -147,7 +149,7 @@ fn table(rows: &[Vec]) -> Lines { } l }; - shown.iter().enumerate().map(|(i, r)| row(r, if i == 0 { "accent" } else { "text" })).collect() + shown.iter().take(PREVIEW_LINES + 1).enumerate().map(|(i, r)| row(r, if i == 0 { "accent" } else { "text" })).collect() } /// Valid JSON laid out two spaces a level, its text kept as written, coloured as the plain preview colours JSON. @@ -198,11 +200,15 @@ fn json(text: &str) -> Option { } /// Headings without their `#`, bullets, quotes behind a bar, links and code spans picked out; code blocks as they are. -fn markdown(text: &str, cols: u16) -> Lines { +/// And whether a line was cut: picking out emphasis is quadratic in a line's length. +fn markdown(text: &str, cols: u16) -> (Lines, bool) { let mut out = Vec::new(); let mut fence = false; + let mut short = false; for raw in text.lines() { - let line = raw.replace('\t', " "); + let end = raw.char_indices().nth(2 * LINE_CELLS).map_or(raw.len(), |(i, _)| i); + short |= end < raw.len(); + let line = raw[..end].replace('\t', " "); let t = line.trim_start(); let indent = line[..line.len() - t.len()].to_string(); if t.starts_with("```") || t.starts_with("~~~") { @@ -243,7 +249,7 @@ fn markdown(text: &str, cols: u16) -> Lines { inline(&mut l, rest, "text"); out.push(l); } - out + (out, short) } /// Code spans in teal without their backticks, a link's text in teal with its target after it, emphasis muted without its marks. @@ -358,6 +364,53 @@ mod tests { assert!(crate::slots::over_cap(&PluginMsg::Preview { req: 1, lines, cut, modes: vec![], mode: String::new() }).is_none(), "under the caps a plugin keeps"); } + /// The table as it was built before only what can show was built: the oracle for its output under the caps. + fn every_row_and_column(rows: &[Vec]) -> Lines { + let shown: Vec> = rows.iter().map(|r| r.iter().map(|f| fit(&clean(&f.replace("\r\n", "\n").replace('\n', "\u{21b5}")), CELL)).collect()).collect(); + let n = shown.iter().map(Vec::len).max().unwrap_or(0); + let widths: Vec = (0..n).map(|j| shown.iter().filter_map(|r| r.get(j)).map(|c| cells(c)).max().unwrap_or(0)).collect(); + shown + .iter() + .enumerate() + .map(|(i, r)| { + let mut l = Vec::new(); + for (j, w) in widths.iter().enumerate() { + if j > 0 { + l.push((" \u{2502} ".to_string(), "faint")); + } + let c = r.get(j).map(String::as_str).unwrap_or(""); + let pad = if j + 1 == n { String::new() } else { " ".repeat(w - cells(c)) }; + l.push((format!("{c}{pad}"), if i == 0 { "accent" } else { "text" })); + } + l + }) + .collect() + } + + #[test] + fn a_table_builds_only_what_shows_and_shows_what_it_showed() { + let huge: String = (0..2100).map(|_| ",".repeat(200) + "\n").collect(); + let t = table(&records(&huge, false)); + assert!(t.len() <= PREVIEW_LINES + 1, "{}", t.len()); + assert!(t.iter().all(|l| l.len() <= 2 * (LINE_CELLS / 3 + 2)), "spans per line"); + let (_, cut) = finish(t); + assert!(cut); + let wide: String = (0..30).map(|r| (0..200).map(|c| format!("{r}{}", "x".repeat(c % 7))).collect::>().join(",") + "\n").collect(); + for text in [wide.as_str(), "name,n\na.rs,12,extra\nb\n\"x\ny\",1\n界界,1\n", &(0..2500).map(|i| format!("{i},{}\n", "y".repeat(i % 50))).collect::()] { + let r = records(text, false); + assert_eq!(finish(table(&r)), finish(every_row_and_column(&r)), "the same under the caps"); + } + } + + #[test] + fn a_long_markdown_line_is_cut_before_it_is_read_and_said_to_be_cut() { + let (m, short) = markdown(&"a".repeat(64 * 1024), 80); + assert!(short); + assert!(text(&m)[0].chars().count() <= 2 * LINE_CELLS); + let (m, short) = markdown(&" *a".repeat(20_000), 80); + assert!(short && !m.is_empty()); + } + #[test] fn json_is_laid_out_as_written_and_coloured_as_the_plain_view_colours_it() { let j = json("{\"a\": 1.5e3, \"b\": [true, null, \"x,{y}\"], \"e\": {}, \"f\": [ ], \"s\": \"q\\\"uote\"}").unwrap(); @@ -376,7 +429,8 @@ mod tests { #[test] fn markdown_is_rendered_into_styled_lines() { - let m = markdown("# Title\n#tag\n- one\n * two\n12. twelve\n> quote `c`\n---\n```\n# not a heading\n```\nSee [site](https://x.y) and `code` **now**, *this* and _that_ but not snake_case or a * b.\n", 20); + let (m, short) = markdown("# Title\n#tag\n- one\n * two\n12. twelve\n> quote `c`\n---\n```\n# not a heading\n```\nSee [site](https://x.y) and `code` **now**, *this* and _that_ but not snake_case or a * b.\n", 20); + assert!(!short); assert_eq!( text(&m), ["Title", "#tag", "• one", " • two", "12. twelve", "│ quote c", &"─".repeat(20), "```", "# not a heading", "```", "See site (https://x.y) and code now, this and that but not snake_case or a * b."] diff --git a/src/release.rs b/src/release.rs index 2b2e77c..128e1c5 100644 --- a/src/release.rs +++ b/src/release.rs @@ -389,7 +389,7 @@ fn due(last: Option, now: u64) -> bool { /// Allowed and due; marked as run at once, so the other servers on this Mac wait a day. pub fn daily_due(now: u64) -> bool { let last = std::fs::read_to_string(checked_path()).ok().and_then(|s| s.trim().parse().ok()); - daily() == Some(true) && due(last, now) && std::fs::write(checked_path(), now.to_string()).is_ok() + daily() == Some(true) && due(last, now) && crate::paths::write_private(&checked_path(), "tmp", now.to_string().as_bytes()).is_ok() } pub fn leave(said: &Said) -> std::io::Result<()> { @@ -411,7 +411,7 @@ pub fn skipped() -> Option { } pub fn skip(tag: &str) -> std::io::Result<()> { - std::fs::write(skipped_path(), tag) + crate::paths::write_private(&skipped_path(), "tmp", tag.as_bytes()) } fn take(p: &Path) -> Option { @@ -533,4 +533,25 @@ mod tests { assert_eq!(daily(), Some(true)); crate::paths::TEST_HOME.with(|h| h.replace(old)); } + + #[test] + fn the_last_check_and_a_skipped_release_are_the_user_s_alone() { + use std::os::unix::fs::PermissionsExt; + let home = crate::paths::test_dir("release-private"); + let old = crate::paths::TEST_HOME.with(|h| h.replace(Some(home.clone()))); + std::fs::create_dir_all(crate::paths::config_path().parent().unwrap()).unwrap(); + std::fs::create_dir_all(crate::paths::state_root()).unwrap(); + set_daily(true).unwrap(); + for p in [checked_path(), skipped_path()] { + std::fs::write(&p, "0").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap(); + } + assert!(daily_due(DAY)); + skip("v0.13.0").unwrap(); + for p in [checked_path(), skipped_path()] { + assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600, "{}", p.display()); + } + assert_eq!(skipped().as_deref(), Some("v0.13.0")); + crate::paths::TEST_HOME.with(|h| h.replace(old)); + } } diff --git a/src/render.rs b/src/render.rs index beada34..a1cac36 100644 --- a/src/render.rs +++ b/src/render.rs @@ -243,11 +243,10 @@ pub fn blit_grid(buf: &mut Buffer, area: Rect, screen: crate::term::Screen<'_>, } /// Characters that aren't visible but change how the terminal renders the -/// rest: control characters, bidi overrides, invisible separators and operators, soft hyphens, tag characters, BOM. +/// rest: control characters and the format characters and line separators `mail::hidden` lists. /// U+200C and U+200D stay: emoji sequences and some scripts are built with them. pub fn is_spoofing(c: char) -> bool { - c.is_control() - || matches!(c as u32, 0x00AD | 0x061C | 0x200B | 0x200E..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x2069 | 0xFEFF | 0xE0000..=0xE007F) + c.is_control() || crate::mail::hidden(c) } pub fn unspoofed(s: &str) -> String { @@ -537,6 +536,7 @@ mod tests { assert_eq!(buf[(2, 0)].symbol(), "\u{1F469}\u{200D}\u{1F4BB}", "an emoji sequence keeps its joiner"); assert_eq!(unspoofed("a\u{202E}b\u{200C}c\x1b"), "ab\u{200C}c"); assert_eq!(unspoofed("w\u{2060}o\u{2064}r\u{00AD}d\u{E0041}\u{E007F}"), "word", "joiners, invisible operators, soft hyphens and tag characters"); + assert_eq!(unspoofed("a\u{2028}b\u{206A}c\u{FFF9}d\u{180E}e\u{600}f\u{6DD}g\u{70F}h"), "abcdefgh", "what the message filter hides, the frame does too"); } fn screen_of(cols: u16, rows: u16, bytes: &[u8]) -> crate::term::Term { diff --git a/src/sandbox.rs b/src/sandbox.rs index 2b6fedb..533dad1 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -15,6 +15,17 @@ pub const PROFILE: &str = concat!( unsafe extern "C" { fn sandbox_init(profile: *const c_char, flags: u64, errorbuf: *mut *mut c_char) -> c_int; fn sandbox_free_error(errorbuf: *mut c_char); + fn sandbox_check(pid: c_int, operation: *const c_char, filter: c_int, ...) -> c_int; +} + +/// Whether this process's Seatbelt lets it write the existing `path`, asked without touching it; a missing path reads as refused. +pub fn may_write(path: &std::path::Path) -> bool { + use std::os::unix::ffi::OsStrExt as _; + const SANDBOX_FILTER_PATH: c_int = 1; + const SANDBOX_CHECK_NO_REPORT: c_int = 0x4000_0000; + let Ok(path) = CString::new(path.as_os_str().as_bytes()) else { return false }; + // SAFETY: both strings are NUL-terminated and outlive the call. + unsafe { sandbox_check(std::process::id() as c_int, c"file-write-data".as_ptr(), SANDBOX_FILTER_PATH | SANDBOX_CHECK_NO_REPORT, path.as_ptr()) == 0 } } pub fn apply() -> Result<(), String> { diff --git a/tests/daemon.rs b/tests/daemon.rs index f084640..f4f9a87 100644 --- a/tests/daemon.rs +++ b/tests/daemon.rs @@ -2734,7 +2734,10 @@ fn a_plugin_hears_only_what_it_subscribed_to_cannot_leave_its_sandbox_and_dies_w assert!(events.contains("\"id\":\"probe\"") && !events.contains("pane_opened"), "{events}"); let rc = |what: &str| read(&results).lines().find_map(|l| l.strip_prefix(&format!("{what} "))?.parse::().ok()); - for what in ["open", "launchctl", "osascript", "tcp", "main-socket", "write-manifest", "write-outside", "read-ssh", "read-fish", "read-zsh-new", "read-link", "read-session", "list-root", "read-other"] { + for what in ["open", "launchctl", "osascript"] { + assert!(read(&results).lines().any(|l| l == format!("{what} refused")), "{what} was not refused at exec: {}", read(&results)); + } + for what in ["tcp", "main-socket", "write-manifest", "write-outside", "read-ssh", "read-fish", "read-zsh-new", "read-link", "read-session", "list-root", "read-other"] { assert!(rc(what).is_some_and(|r| r != 0), "{what} went through: {}", read(&results)); } for what in ["write-data", "read-own", "read-home"] { @@ -2743,8 +2746,6 @@ fn a_plugin_hears_only_what_it_subscribed_to_cannot_leave_its_sandbox_and_dies_w assert_eq!(read(&data.join("fd3")), "closed\n", "it inherited the broker's socket"); assert_eq!(read(&env.dir.join("plugins/probe/plugin.json")), manifest); assert!(!env.dir.join("outside").exists()); - let listed = Command::new("/bin/launchctl").arg("list").output().unwrap(); - assert!(!String::from_utf8_lossy(&listed.stdout).contains("dev.nerd.probe."), "launchd took a job from it"); assert!(daemon_log(&env).contains("plugin probe: report for pane 1 refused"), "a pane without its agent: {}", daemon_log(&env)); assert_eq!(read(&data.join("again.out")), "refused: plugin \"probe\" is already connected\n", "a second Connect, answered and closed"); // Its detached grandchild: only Connect on the plugin socket, and the main socket out of reach. diff --git a/tests/egress.rs b/tests/egress.rs index d8d9dc3..b6425f6 100644 --- a/tests/egress.rs +++ b/tests/egress.rs @@ -83,8 +83,8 @@ const ALLOW: &[(&str, &str, &str, &str, &str, &str)] = &[ ("src/client.rs", "switch_in_place", "spawn", "exe --resume", "local", "becomes nerd --resume N in the same terminal when you open another server of this Mac in this session"), ("src/daemon.rs", "start", "spawn", "& exe --version", "local", "asks the installed build its version"), ("src/daemon.rs", "handover_exec", "command", "std :: env :: current_exe ()", "local", "update in place: the new build takes over the open panes and sockets"), - ("src/git.rs", "run_git", "spawn", "\"git\"", "local", "the file tree's git, without lazy fetch, fsmonitor or filters"), - ("src/git.rs", "read_git", "spawn", "\"git\"", "local", "the file tree's git, without lazy fetch, fsmonitor or filters"), + ("src/git.rs", "run_git", "spawn", "\"git\"", "local", "the file tree's worktree add and remove, when you choose them"), + ("src/git.rs", "read_cmd", "spawn", "\"git\"", "local", "the file tree's git, without lazy fetch, fsmonitor or filters"), ("src/install.rs", "git_exe", "spawn", "\"/usr/bin/xcode-select\"", "local", "checks that Apple's git is installed"), ("src/install.rs", "git_exe", "spawn", "& exe --version", "local", "asks git its version"), ("src/notify.rs", "run", "spawn", "cmd", "local", "runs what a notify call below names"), diff --git a/tests/fixtures/plugins/plugin.sh b/tests/fixtures/plugins/plugin.sh index dce1648..499945c 100644 --- a/tests/fixtures/plugins/plugin.sh +++ b/tests/fixtures/plugins/plugin.sh @@ -32,10 +32,21 @@ try() { "$@" > /dev/null 2>&1 < /dev/null || r=$? echo "$what $r" >> results.log } +# The exit code cannot tell a refused exec from a program that ran and failed; the shell's message can. +exec_refused() { + what=$1 + shift + e=$("$@" 2>&1 > /dev/null < /dev/null) || true + case $e in + *"$1: Operation not permitted"*) echo "$what refused" >> results.log ;; + *) echo "$what ran" >> results.log ;; + esac +} probe() { - try open /usr/bin/open -a TextEdit - try launchctl /bin/launchctl submit -l "dev.nerd.probe.$$" -- /usr/bin/true - try osascript /usr/bin/osascript -e 'return 1' + # Arguments that start nothing even if exec were allowed. + exec_refused open /usr/bin/open + exec_refused launchctl /bin/launchctl version + exec_refused osascript /usr/bin/osascript -e 'return 1' try tcp /usr/bin/nc -z -w 2 1.1.1.1 80 try main-socket /usr/bin/nc -w 2 -U "$main" try write-manifest /bin/sh -c "echo x >> '$here/plugin.json'" diff --git a/tests/pane_exec.rs b/tests/pane_exec.rs index 12f6772..f48f619 100644 --- a/tests/pane_exec.rs +++ b/tests/pane_exec.rs @@ -21,7 +21,7 @@ impl Pane { for d in [&dir, &socks] { let _ = std::fs::remove_dir_all(d); } - for d in [dir.join("home"), dir.join("state"), dir.join("work"), socks.clone()] { + for d in [dir.join("home/Library/Caches"), dir.join("home/.claude/projects/p/memory"), dir.join("home/.gemini"), dir.join("state"), dir.join("work"), socks.clone()] { std::fs::create_dir_all(d).unwrap(); } let dir = dir.canonicalize().unwrap(); @@ -120,6 +120,7 @@ fn folder_and_no_network_keep_the_pane_in_its_folder_and_off_every_socket_but_th {CONNECT} \"$2\"; echo \"msock $?\" >> out\n\ echo \"state ${{NERD_STATE-unset}} pane $NERD_PANE\" >> out\n\ /usr/bin/nc -z -G 3 192.0.2.1 80 2>/dev/null; echo \"tcp $?\" >> out\n\ + for c in com.apple.python Homebrew other; do mkdir -p \"$HOME/Library/Caches/$c\" 2>/dev/null; echo x 2>/dev/null > \"$HOME/Library/Caches/$c/x\"; echo \"cache $c $?\" >> out; done\n\ echo > done\n" ); let pane = Pane::start("none", &["--files", "folder", "--network", "none"], &script, &[]); @@ -130,9 +131,46 @@ fn folder_and_no_network_keep_the_pane_in_its_folder_and_off_every_socket_but_th assert!(out.contains("msock 0\n"), "reaches the msock: {out}"); assert!(out.contains("state unset pane 7\n"), "no NERD_STATE, its own NERD_PANE: {out}"); assert!(!out.contains("tcp 0\n"), "no TCP: {out}"); + assert!(!out.contains("cache com.apple.python 0\n") && !out.contains("cache Homebrew 0\n"), "caches that run code later stay read-only: {out}"); + assert!(out.contains("cache other 0\n"), "other caches stay writable: {out}"); assert!(!pane.dir.join("home/x").exists()); } +#[test] +fn folder_only_cannot_move_a_folder_above_a_denied_one_nor_link_one_in() { + let script = "P=\"$HOME/.claude/projects\"\n\ + mkdir -p swap/memory; /usr/bin/perl -e 'syscall(488, -2, $ARGV[0], -2, $ARGV[1], 2) == 0 or exit 1' \"$P/p\" swap; echo \"swap project $?\" > out\n\ + mv \"$P/p\" moved 2>/dev/null; echo \"move project $?\" >> out\n\ + mv \"$HOME/Library/Caches\" caches 2>/dev/null; echo \"move caches $?\" >> out\n\ + mkdir -p planted/memory; ln -s \"$PWD/planted\" \"$P/q\" 2>/dev/null; echo \"link project $?\" >> out\n\ + mkdir \"$P/n\" && echo s > \"$P/n/s.jsonl\"; echo \"new project $?\" >> out\n\ + echo s > \"$P/p/s.jsonl\" && mv \"$P/p/s.jsonl\" \"$P/p/t.jsonl\" && rm \"$P/p/t.jsonl\"; echo \"session $?\" >> out\n\ + mkdir -p \"$HOME/Library/Caches/other\" && echo x > \"$HOME/Library/Caches/other/x\"; echo \"cache $?\" >> out\n\ + mkdir -p staged/bin; /usr/bin/perl -e 'rename($ARGV[0], $ARGV[1]) or exit 1' staged \"$HOME/.gemini/tmp\"; echo \"move agent folder in $?\" >> out\n\ + echo > done\n"; + let pane = Pane::start("rename", &["--files", "folder", "--network", "none"], script, &[]); + pane.wait_for("done"); + let out = pane.wait_for("out"); + for no in ["swap project", "move project", "move caches", "link project", "move agent folder in"] { + assert!(!out.contains(&format!("{no} 0\n")), "{no}: {out}"); + } + for ok in ["new project", "session", "cache"] { + assert!(out.contains(&format!("{ok} 0\n")), "{ok}: {out}"); + } + assert!(pane.dir.join("home/.claude/projects/p/memory").is_dir() && pane.dir.join("home/Library/Caches").is_dir()); + assert!(std::fs::symlink_metadata(pane.dir.join("home/.claude/projects/q")).is_err()); + assert!(pane.dir.join("home/.gemini/tmp").is_dir() && !pane.dir.join("home/.gemini/tmp/bin").exists(), "an agent's missing folder above a denied one is made before the pane starts"); +} + +#[test] +fn folder_only_finds_the_session_folder_writable_and_leaves_nothing_in_it() { + let pane = Pane::start("writecheck", &["--files", "folder", "--network", "none"], "echo > done\n", &[]); + pane.wait_for("done"); + assert!(!pane.read("stderr").contains("not writable"), "{}", pane.read("stderr")); + let left: Vec<_> = std::fs::read_dir(pane.dir.join("home/.claude/projects")).unwrap().filter_map(|e| e.ok()?.file_name().into_string().ok()).filter(|n| n != "p").collect(); + assert!(left.is_empty(), "the write check leaves {left:?}"); +} + #[test] fn only_its_provider_survives_ctrl_c_and_goes_with_its_program() { let echo = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/tests/release.rs b/tests/release.rs index e75782d..7493d37 100644 --- a/tests/release.rs +++ b/tests/release.rs @@ -186,6 +186,12 @@ fn a_release_is_installed_only_for_the_window_s_click_and_only_when_nerd_s_key_s assert_eq!(std::fs::read(&nerd).unwrap(), before, "nothing installed"); assert!(!go.exists(), "a click is good for one run"); + e.archive("#!/bin/sh\necho 'nerd 0.98.0 (test)'\n"); + std::fs::write(&go, TAG).unwrap(); + e.run(&nerd, &["--check-update", "--install", "--window"], &[]); + assert_eq!(e.said()["Failed"], "the archive holds nerd 0.98.0, not 0.99.0, nothing installed"); + assert_eq!(std::fs::read(&nerd).unwrap(), before, "nothing installed"); + e.archive("#!/bin/sh\necho 'nerd 0.99.0 (test)'\n"); std::fs::write(&go, "v0.98.0").unwrap(); e.run(&nerd, &["--check-update", "--install", "--window"], &[]);