diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2bebeb..7f63d51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,8 +15,10 @@ jobs: os: [macos-15, ubuntu-24.04] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v5 - - uses: oven-sh/setup-bun@v2 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.4 - run: bun run test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8de7b5..bbb9f62 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,8 +19,10 @@ jobs: id-token: write # sign attestations attestations: write steps: - - uses: actions/checkout@v5 - - uses: oven-sh/setup-bun@v2 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.4 @@ -41,7 +43,7 @@ jobs: run: bun scripts/sign.ts dist/release/SHA256SUMS - name: Attest build provenance - uses: actions/attest-build-provenance@v4 + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: dist/release/* diff --git a/AGENTS.md b/AGENTS.md index 93d9540..53d8cd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,12 @@ bun run build:local # binaries + the app for this machine (needs X - **Terminal input is the user's.** On macOS ⌘-shortcuts belong to the app; on Linux Ctrl+key combinations stay with the shell unless the README lists them. +- **These rules are tested.** `test/invariants.static.test.ts` checks the ones above against the + source, `test/http.routes.test.ts` applies auth/Host/CSRF/header rules to every route it finds in + `http.ts`, and `test/redos.matrix.test.ts` runs every highlighter and symbol language against + pathological input. A new innerHTML interpolation must be escaped or added to the reviewed list + there with a reason; a new language is covered automatically (`LANG_IDS`, `SYMBOL_LANGS`). + ## Client conventions - UI scale: `--ui-scale = codeSize / BASE_SIZE`, applied with CSS `zoom` on chrome containers. diff --git a/DESIGN.md b/DESIGN.md index a838da2..c4ff7ee 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -939,3 +939,114 @@ on-screen `WKWebView` set up like the Mac window reproduced it, and a second the | `.icon-btn` has `transition: color`. A transition started by a theme change does not advance while WebKit is not rendering, so after a switch in a hidden or minimized window the page was already in the new theme and the buttons were not (computed `rgb(139, 148, 165)`, the dark colour, on the light title bar at 2.8:1). The omnibox, sash and other transitioned colours are exposed the same way. | `setDocumentTheme` applies a theme under `data-theme-switching`, which turns all transitions off, and forces a layout so the new styles commit before they return. Buttons match on reveal in all four flips tried, in computed colours and in pixels. | | In the Mac app "system" never followed macOS. WKWebView's `prefers-color-scheme` follows the window, and the window's appearance was always forced from the page, so "system" read back the page's last choice: light on a Dark Mac after Light, dark on a light Mac from launch. | The theme message carries the mode, and system sets `window.appearance = nil`. The `matchMedia` listener then re-applies the theme with the real value. | | Not checked. `takeSnapshot` draws web content only and window capture needs Screen Recording permission, so the native toolbar over the title bar was not captured in the light theme. | None needed so far: the user's screenshot of the dark state matches the snapshot pixels. | + +## 24. Round 15: bug and security sweep + +A review of the server routes, the git layer, the terminal sockets, the updater and the client's +rendering of untrusted text. + +| Finding | Fix | +| --- | --- | +| Security (high). A partial clone (`--filter=blob:none`) fetches missing objects on demand from its promisor remote, and the repository's own config names the program that does it (`remote.origin.uploadpack`, `core.sshCommand`). Rename detection in Changes, blame, File History and `/api/git/base?rev=` all read blobs, so opening such a folder ran a command (reproduced with git 2.43). `protocol.*.allow` in a hostile config wins over a `protocol.allow` override. | `ENV` sets `GIT_ALLOW_PROTOCOL=""`, which git checks before any config, so no transport is allowed, and `GIT_NO_LAZY_FETCH=1` (git 2.44+). `test/git.lazyfetch.test.ts`. | +| A hostile `blame.ignoreRevsFile` (a missing file, or `/dev/zero`) made every blame fail or run to the 30 s timeout. | `blame --no-ignore-revs-file`. A `-c blame.ignoreRevsFile=` override does not work: git sorts the values, so the empty one resets the list before the hostile ones are added (found by the adversarial pass below). A repository's real ignore list is not applied. | +| `Git.open` rejected a viewed folder whose name starts with `..` (`..cache/`) as outside the work tree. | Only `..`, `../…` or an absolute relative path are outside. | +| A PTY text frame of valid non-object JSON (`null`) threw in the WebSocket handler, which exits the server. | Non-objects are ignored. | +| `/ws/pty?cols=80.5` reached the PTY with a fractional size. | Floored, like resize messages. | +| The updater accepted any tag that merely starts with `X.Y.Z`; the version becomes part of a staging file name (`1.2.3/../../x`). It still needed a signed checksum line, so this is defence in depth. | The whole tag must be a semver version. | +| The live index listed a newly created symlink to a file outside the root, which a full rebuild never lists. | `FileIndex.apply` checks containment like `build()`. | +| Trojan Source in the main code view and the HEAD peek: bidi overrides reordered the line as drawn, though round 4 (§13) set visible escapes as the rule. Escapes there would shift every DOM offset that native selection and copy rely on. | `markBidi` wraps each override/isolate in a zero-width inline-block `.bidi`, drawn as a red bar with the code point in its title. An atomic inline reorders nothing around it; the character stays in the DOM, so selection and copy are unchanged. | +| File names, tab titles, breadcrumbs, the commit pill, palette rows, File History details, the status bar root and branch, blame author/summary and terminal titles put bidi overrides and controls on screen raw (`invoice‮fdp.ts`). | `showHidden` there, as Changes already did. | +| Every tab in the diff view, search previews and Markdown code showed as the text `\x09`. | `showHiddenText` (HIDDEN minus the tab) for file contents; names keep the strict rule. | +| `parseTarget` threw on a ⌘-clicked OSC 8 target holding U+2028/U+2029 (`.` does not match them); `codeviewer open` had the same regex. | The `s` flag. | +| `esc` left `'` alone (no single-quoted attribute uses it today). | Escaped as `'`. | +| Not changed. OSC 52 from a hidden terminal still writes the clipboard, with the notice (the trade-off chosen in round 3, §11). The search test that expects `a.txt` over its hard link `a-hardlink.txt` depends on readdir order (the same inode is reported once, under whichever name comes first). | — | + +### Adversarial pass + +Five reviewers attacked the HTTP layer, git, the terminal, the client views and the fixes above; +each finding below was reproduced before it was fixed. + +| Finding | Fix | +| --- | --- | +| Any request whose handler threw got Bun's development error page: HTML, no security headers, the process cwd, file paths and source. `Host: [` did it before auth (`new URL` throws). | `development: false`, an `error` handler returning a JSON 500, and the Host check before the URL is parsed. | +| `PUT /api/settings` capped the request, not the file: indented output grows with the square of the nesting depth (6 KB of `[` wrote 18 MB, 40 KB wrote 800 MB, and the server ran out of memory). | Settings deeper than 32 levels are refused and the serialized file is capped at 1 MB. | +| A parent directory swapped for a symlink between `resolveSafe` and `open` read a file outside the root (O_NOFOLLOW guards the last component only; reproduced with a swap loop). The search worker re-joined the path the same way. | `openRegular(abs, root)` checks the file actually opened (Linux: `/proc/self/fd`; elsewhere a fresh realpath must reach the same inode). The worker opens the path it checked. Listings are not covered (names only). | +| `IMAGE_MIME`/`MIME` lookups found `Object.prototype` keys: `z.constructor` was served as `Content-Type: function Object() {…}`. | Own keys only. | +| `?limit=1.5` sent `content-length: 1.5` and reset the connection. | Floored. | +| `/api/open` accepted the root, folders and FIFOs. | Regular files only. | +| On POSIX `\` was a path separator: `a\b` opened `a/b`. | `SEP_RE` splits on `\` only on Windows. | +| A file created while the index was being built was missed until the next structural change. | `apply()` during a build invalidates it; a build overtaken by `invalidate()` walks again (three times at most). | +| Driver keys from `git config --get-regexp` were split on newlines. | `-z`. | +| Main-thread hangs on untrusted files: the JS lexer's regex-after-word check ran `/[A-Za-z_$][\w$]*$/` over the whole prefix per `/` (10 KB line: 76 s; a 12.6 KB file froze the app 4.6 s); Java/C# and TOML symbol patterns with long space runs (cubic); Markdown headings without the line cap; `headingText` on `[` runs; the YAML key pattern on indented lines; bare-URL `)` trimming recounting parentheses. | A bounded backward scan in the lexer; space runs collapsed before symbol patterns; `MAX_LINE` for headings; unambiguous patterns; parentheses counted once. `test/client.redos.test.ts` times each. | +| Terminal output grew one cell without bound: combining marks attach to the previous cell, and REP of a cluster ending in ZWJ joined every repeat into it (800 bytes: a 13M-character cell; 48 KB killed the process). | A cluster holds at most 64 UTF-16 units; REP repeats the base code point only. | +| REP amplification: 8 KB of `CSI 65535 b` scrolled for 12 s on the main thread. | At most `cols × rows` repeats (more only overwrite the screen again). | +| OSC 8: `links.lastIndexOf` per link was quadratic (65 000 links: 81 s), targets were unbounded, and after 0xfffe links the ids wrapped, so an earlier link (`README`) opened the newest target. | A `uri → id` map, targets over 2 KB are plain text, and a full table adds no links (ids are never reused) until reset. | +| `OSC 4 ;1;?` repeated a megabyte long sent 200 000 replies into the shell's input. | At most 256 answers per sequence. | +| Replay after the ring trimmed restored the modes from before the first chunk and skipped its head, so a TUI that exited in those bytes came back as alt screen + mouse + paste over a shell prompt. The server's tracker also missed sequences split after 24 bytes, C1 CSI (`C2 9B`) and `:` sub-parameters, all of which the client honours. | `applyModes`/`modesPrefix`: the skipped head is applied to the prefix; the carry runs from the last escape (256 bytes at most); C1 and `:` are parsed (the "latin1" decoder is windows-1252, where 9B is U+203A); a hidden cursor is restored too. | +| Deleting the viewed folder exited the server: `Bun.spawn` throws on a missing cwd, and the status refresh timer's rejection was unhandled (exit 1). It was also the "flaky" PTY replay test: an earlier test's refresh fired after its folder was removed. | `run()` reports a failed spawn as a failed git call; the refresh timer catches. | +| Not changed: OSC 52 from `cat` (the notice stays the safeguard); C0 controls inside a CSI are not seen by the server's tracker; an invalid range (`bytes=5-3`) stays 416 (pinned by a test, allowed by RFC 9110); search previews collapse a tab to one space (`nowrap`); palette highlights drift when lowercasing changes a string's length (`İ`); bidi overrides in rendered Markdown prose; in-file Find runs the user's own regex on the main thread; `--host localhost` in the Chromium app window (the resolver rules map every host name) was not checked. | — | + +### Second adversarial pass + +| Finding | Fix | +| --- | --- | +| Updater: `FileSink.write` was not awaited and the hash covered the bytes received, not the bytes written. On a full disk (a 256 KB tmpfs) a truncated CLI binary passed the checksum and replaced the real one. | Writes are awaited, and the checksum is taken from the file on disk, whose size must match. Reproduced before and after: now ENOSPC fails the update and the old binary stays. | +| A tag such as `v2.0.0-rc.1` published without the prerelease flag would have been offered to everyone, contradicting SECURITY.md. | Versions with a pre-release part are never offered. | +| If the new install could not be moved in and the old one not moved back, the staging dir (holding the only copy) was deleted. | It is kept, and the error names it. | +| Relaunch after an update dropped `--host` and `--shell`. | Forwarded (`--port` is not: the old process still holds it). | +| Release signing ran in a job whose third-party actions were pinned by mutable tags, and checkout left the job token in `.git/config` for the build. | Actions pinned to the commits their tags named (checkout v5.1.0, setup-bun v2.2.0, attest-build-provenance v4.2.2); `persist-credentials: false`. | +| Security (critical). `git diff` looked inside submodules (it runs `git status` in each), and a submodule's own config is never read by the overrides: its filter ran from the Changes view. | `diff.ignoreSubmodules=all` in `HARDEN` and `--ignore-submodules=all` on the diff. | +| Security (critical). The driver listing was capped at 1 MiB and parsed even when cut: a config padded past the cap left the drivers after it armed. | 16 MiB, and a cut or failed listing fails closed: no git call runs. | +| Security. The overrides were cached until `.git/config` changed, but a config can include files the watcher does not see (a tracked file via `include.path`, `~/.gitconfig`): a driver added there later ran. | The drivers are listed again at the start of each operation (status, changes, blame, history, base, file list). | +| `diff.renameLimit=0` made status and Changes run into the 30 s timeout on large renames and come back as a clean tree; `i18n.logOutputEncoding=UTF-16` broke history, blame and the HEAD line; `log.showRoot=false` dropped the commit that added a file. | Rename limits of 1000, UTF-8 log output and `--root` forced; a timed-out diff is reported as truncated. | +| Search found nothing in a viewed folder that is itself git-ignored (git lists no files there). | An empty git list falls back to the file index. | +| A shell that could not start (deleted folder, bad `--shell`, out of fds) threw in the socket handler and exited the server; sessions were unlimited. | That terminal gets an exit notice and is closed; at most 64 live shells. | +| Deleting files rescanned the whole index per path: a 1 500-file `rm` froze the server for 20 s on a 200 000-file tree. | Only a path that was a directory is scanned for children. | +| A file written more often than every 200 ms (a log) held git status back for as long as it was written: every poke restarted the wait. | Throttled: a pending refresh keeps its time. | +| Regressions from the first pass: a capped REP left the cursor elsewhere; heavy-dir events restarted the index build; search reported a match under a symlink instead of its target; tooltips still showed raw names; two doc comments ended up on the wrong code. | REP runs the cap plus the remainder of one row's period; heavy dirs don't invalidate; a link whose target is also listed is skipped; tooltips escaped per line; comments moved. | +| Client: a second jump into a file still loading was undone by the first; reconnecting the events socket resynced nothing; Changes counts went stale on a second edit of a modified file; ⌘B/⌘J did nothing from the terminal on macOS; image tabs never revoked their blob: URLs; Enter confirming an IME composition ran the palette's top item; the change gutter kept an old HEAD base after a discarded fetch. | Each fixed; the reproductions were rerun in Chromium against the build. | +| Settings: each window PUT its whole snapshot, so two windows (or two folders) erased each other's keys, the snapshot grew forever (`tabs:`), and over 64 KB the save on close was dropped (keepalive limit); a layout change in the last 200 ms was lost. | `PATCH /api/settings` with only the changed keys, merged on the server in the write queue; the 100 most recent `tabs:` roots are kept; layout flushes before the save on close. | +| Not changed: signing still shares the build job; the index caps file count, not path bytes or directories; the recursive watcher can use the account's whole inotify budget on huge trees; `/api/list` on a 1M-entry directory takes ~10 s (sort); a repository path containing a newline gets no git integration; OSC 8 links stop for good after 65 534 distinct targets (moving it to its own job is a workflow redesign); `codesign --verify` without a requirement only proves a consistent signature (releases are ad hoc signed); a Linux update drops a setuid `chrome-sandbox`; redirects of signed downloads are not host-checked (the signature covers them). | — | + + +### Third adversarial pass (eight experts) + +Auth and origins, filesystem containment, hostile git repositories, the terminal, client rendering, +the updater and supply chain, hostile-content denial of service, and the native wrappers. Every +finding was reproduced before it was fixed. + +| Finding | Fix | +| --- | --- | +| Critical. The compiled server autoloaded `bunfig.toml` and `.env` from its working directory, and the Mac app (and `codeviewer .`) runs it in the viewed folder: a repository's `preload` ran inside the server; its `.env` set variables for every shell and git process. Reproduced with a binary built by `build.ts`. | `--no-compile-autoload-{bunfig,dotenv,tsconfig,package-json}`; the Mac app starts the server in `/`. Pinned by `test/invariants.static.test.ts`. | +| Critical. `git diff` refreshes the index on stat-dirty files (an extracted tarball) despite `GIT_OPTIONAL_LOCKS=0`; writing the index ran the repository's own `post-index-change` hook from the Changes view (and rewrote the index of a repository a `.git` file pointed at). | `diff.autoRefreshIndex=false` and `core.hooksPath=/dev/null` for every call; stat-only entries are dropped (no numstat line; binaries hash-checked with `hash-object --no-filters`, which never writes). `test/git.hostile-config.test.ts` arms every hook and program-naming key and snapshots `.git` before and after. | +| Medium. Any process in a terminal could read the session token from the app window's on-disk profile (History, Favicons, Session Storage) and attach to the other terminals (input injection into a `sudo -i` tab, verified). | `--incognito`; boot page deleted when the page connects; `removeWindowFiles()` on quit (process.exit skipped the cleanup); the token URL is printed only to a terminal. | +| Medium. A `.git/commondir`, `config`, `HEAD`, `info/*` or `objects/info/alternates` symlinked to /dev/zero (or a FIFO) made git read until memory ran out. Inherited `GIT_DIR`/`GIT_WORK_TREE` pointed git at another repository. | `preflight` before git runs and `gitDirFilesOk` before every operation: those files must be regular and of a sane size, or git is off for the folder. Locator variables are dropped from the run environment. | +| Medium. Search could still read outside the root: a parent swapped for a symlink between the check and the open (259 outside hits in 40 searches), and the root re-resolved per search followed a root replaced by a symlink. FIFOs and devices were opened before their type was checked. | The worker uses the startup root, stats before opening, and checks the opened fd (`/proc/self/fd`, else inode); `openRegular` stats first too. | +| Medium. The server's mode tracker (a regex) and the page's parser disagreed on C1 CSI after ESC or inside strings, and on mouse modes: hostile output could turn bracketed paste off in the replay only, so after a reload a pasted command ran line by line. | `ModeTracker` mirrors the client's parser state machine; `test/pty.tracker.diff.test.ts` feeds 2000 seeded streams to both and compares the restored modes. | +| Medium. The updater replaced whatever looked like an install: a slim binary in `~/.local/bin` next to an unrelated `chromium/` had the whole folder swapped away; any `X.app/Contents/Resources/codeviewer`, and any bun runtime not named exactly `bun`. A concurrent check could change the release `apply` installed. The API answer and checksum files were read uncapped. | Package marker files and the bundle identifier are required; only a `--compile` binary updates; `apply` installs only the release its own check found, if newer; every read is capped; errors name the asset, not the API's URL. | +| Low. WebRTC bypassed the app window's dead proxy (STUN to any host); the Mac `relaunch` message was honoured at any time from any frame; `ln -sf` for the command-line tool followed a planted symlink; `codeviewer open` trusted CODEVIEWER_URL and followed redirects. | `--webrtc-ip-handling-policy=disable_non_proxied_udp`; relaunch only from the main frame after the bundle on disk changed; `ln -sfh`; loopback/own-interface URLs only, `redirect: "error"`. | +| Low. The settings temp file had a predictable name and followed symlinks; JSON responses had no `cache-control` (cached in a `--browser` profile); any site could detect a running codeviewer by loading its favicon. | Random name with `wx`; `no-store`; `Cross-Origin-Resource-Policy: same-origin`. | +| Low. Terminal: BEL and title floods forced a layout per character (200 KB of BEL froze the page 2.3 s, and again on every reload); legacy mouse reports past column 94 leaked a byte as a keystroke; DECRQM echoed any prefix; a reference to `x$(cmd).md` pasted at a prompt would run `cmd`. | Bell and tab re-render once per batch, no bell while replaying; legacy reports only while they fit in 7 bits; DECRQM answers `""`/`?` only; references with command substitution are refused. | +| Low. An SVG image in a preview, opened via "Open image in new tab" from its blob: URL, ran its script in the app's origin (the token was out of reach only by accident of sessionStorage partitioning). | Outside the Mac app (which cancels blob: navigations) SVGs are drawn from data: URLs, whose documents have an opaque origin. | +| Found by the new slow-input matrix: the CSS lexer sliced and regex-tested the rest of the line per identifier (quadratic: 64 KB took 650 ms); `headingText` rescanned to the end for every unclosed link. | A bounded scan with the line's last `;`/`{`; `stripLinks`, a one-pass equivalent of the regex (20 000 random strings agree). | +| DoS (high), my regression from the first pass: a marker element per bidi control (248 000 for 300 lines of U+202E: seconds of layout, gigabytes of memory). | One marker per run, at most 32 per line; past that, visible escapes (never raw controls). | +| DoS (high). An SVG of nested `` (1.6 KB, a million elements) hung the image tab and Markdown previews; a 389 KB PNG declaring 20000×20000 pixels took 2 GB. | `imagecheck.ts` before anything is decoded: the `` reference graph is expanded with a cap of 10 000 (cycles refused), raster headers (PNG, GIF, JPEG, WebP, BMP) limited to 50 Mpx. | +| DoS (high). The HEAD peek built every removed line of a hunk (8 MB of `$Id$` lines under `ident`: 1.6 million rows, past 3 GB). | 500 rows of 4000 columns, then "… N more lines". | +| DoS (high). Lexing full 10 000-character lines was quadratic for unterminated JS regexes (100 lines of `(/[`: 10.5 s) and shell `${`. The matrix had no multi-character openers. | Lexing stops at 2000 columns (views colour 1000), regex literals scan 512 characters, `${…}` stops at the next `{`; the matrix gained the opener shapes. | +| DoS (medium). A 6 MB rewrite of distinct lines took 2.4 s to intern before Myers gave up; every cursor move started a blame that was never cancelled (16 left running, 17 s each). | Middles over 200 000 lines are one replaced hunk; a new blame aborts the previous request and the server kills its git process. | +| Not changed (recommendations): move the release signing key to a protected GitHub Environment with required reviewers and verify that tags point at `main`; pin hashes of the Bun runtimes the release cross-compiles with (`--compile-executable-path`); `--signer-workflow` in the landing page's attestation command; huge diffs (40 MB of 2-byte lines) still cost git ~1.7 GB within its timeout; Markdown lazy lines under 32 nested quotes copy each line per level (linear, 1.9 s for 1 MB); a 200-column table is ~1 s of layout. | — | + +#### Tests that pin the rules + +The rules in AGENTS.md are now checked by tests that cover code written later, not only the bugs +above: `invariants.static.test.ts` (no dependencies, the only network calls, git only through the +hardened runner, HARDEN literal and complete, tokens compared with `tokenOk`, no server logging, +reads only through `files.ts`, Markdown without innerHTML, every client innerHTML interpolation +escaped or reviewed, the exact CSP, compile flags, app window flags), `http.routes.test.ts` (every +route found in `http.ts`: 401 without the token for every method, the open-only token reaches only +`/api/open`, 421 for foreign Hosts, 415 for non-JSON writes, security headers, no 5xx), +`redos.matrix.test.ts` (every highlighter and symbol language and the Markdown parser against +pathological input, by growth rate), `terminal.fuzz.test.ts` (seeded hostile output: no throw, +consistent screen, bounded cells, no reply echoing attacker text, nothing while replaying), +`files.resolve.fuzz.test.ts` (random paths through in/out/loop symlinks never resolve or open +outside), `git.hostile-config.test.ts` and `pty.tracker.diff.test.ts` (above). diff --git a/SECURITY.md b/SECURITY.md index bcbba2e..9b484d9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,24 +22,45 @@ itself would be a bug: please report it as a vulnerability. The session token travels only in the URL fragment and a request header, never in a cookie or a process environment. Shells started by Codeviewer receive a separate, narrower token that the server honours for one purpose: `codeviewer open ` asking the viewer to show a file. It cannot read -files, search or attach to terminals. +files, search or attach to terminals, and `codeviewer open` sends it only to a codeviewer on this +machine (loopback or an own interface, no redirects). + +The app window keeps nothing on disk (`--incognito`): a normal browser profile recorded the launch +URL, so a process in a terminal could have read the session token there and typed into the other +terminals. Its boot page is deleted as soon as the page connects, and the token URL is printed only +to a terminal. With `--browser` the token lives in your own browser's session storage and history for +as long as the server runs; it is useless once the server stops. + +The compiled server never loads `bunfig.toml` or `.env` from its working directory, which is often the +folder being viewed (a repository's `bunfig.toml` could otherwise preload its own code into the +server). Running from source (`bun src/server/main.ts`) does load them: start it from outside the +folder you view. ## Git -Every git call runs with filters, textconv, diff drivers, fsmonitor, signature checks, mailmap and -submodule recursion off, with driver overrides passed as `GIT_CONFIG_KEY_n`/`VALUE_n`, and never -writes the index. File History runs `git --literal-pathspecs log --follow` for one file that must +Every git call runs with filters, textconv, diff drivers, fsmonitor, signature checks, mailmap, +hooks (`core.hooksPath=/dev/null`) and submodules off, with driver overrides passed as +`GIT_CONFIG_KEY_n`/`VALUE_n`, and never writes the index (`diff.autoRefreshIndex=false`: a diff +otherwise refreshes it, and writing the index runs the repository's `post-index-change` hook). +Files git reads whole (`config`, `HEAD`, `commondir`, `info/*`, `objects/info/alternates`, …) must +be regular files of a sane size, or git is not used for that folder; `GIT_DIR`-style variables from +the environment are dropped. No git call may use a transport (`GIT_ALLOW_PROTOCOL` empty, `GIT_NO_LAZY_FETCH`): +a partial clone would otherwise fetch missing objects from its promisor remote, running the +programs the repository's config names for it (`remote.*.uploadpack`, `core.sshCommand`). File History runs `git --literal-pathspecs log --follow` for one file that must exist inside the served root, at most 500 commits and 4 MB of output; each side of a commit's diff is read with `cat-file` by full object id, never from a revision expression the client chose. Commands that type into a terminal ("Insert Reference in Terminal") send one line as a paste and -never press Enter. +never press Enter, and refuse file names with control characters or command substitution. +After a reload the server restores the terminal's input modes (bracketed paste, mouse) with a parser +that mirrors the page's, so output cannot make the two disagree. ## Updates The in-app updater downloads only from GitHub over https, caps the sizes it will read, requires a valid Ed25519 signature on `SHA256SUMS` from `packaging/update-key.pub`, verifies the file's SHA-256 -and swaps the installation atomically. Prerelease versions are never offered. +and swaps the installation atomically. The checksum is taken from the file as written, so a short +write (a full disk) fails it. Prerelease versions (a `-` in the tag) are never offered. ## Markdown preview diff --git a/packaging/macos/main.swift b/packaging/macos/main.swift index a57b203..2576127 100644 --- a/packaging/macos/main.swift +++ b/packaging/macos/main.swift @@ -37,7 +37,9 @@ final class Server { ready = done process.executableURL = exe process.arguments = [folder.path, "--no-open", "--machine", "--exit-with-parent"] - process.currentDirectoryURL = folder + // Not the viewed folder: nothing the server or its runtime might read from its working + // directory (bunfig.toml, .env) may come from the repository being viewed. + process.currentDirectoryURL = URL(fileURLWithPath: "/") // Apps launched from Finder get launchd's minimal environment. var env = ProcessInfo.processInfo.environment if env["SHELL"] == nil, let pw = getpwuid(getuid()), let sh = pw.pointee.pw_shell { env["SHELL"] = String(cString: sh) } @@ -250,7 +252,11 @@ final class ViewerWindow: NSObject, NSWindowDelegate, WKNavigationDelegate, WKUI return } if t == "relaunch" { - (NSApp.delegate as? AppDelegate)?.relaunch() + // Only once an update has replaced the bundle, and only from the page itself: script in + // the page must not be able to restart the app (ending every window's shells) at will. + if message.frameInfo.isMainFrame && AppDelegate.bundleWasUpdated() { + (NSApp.delegate as? AppDelegate)?.relaunch() + } return } if t == "dblclick" { @@ -448,6 +454,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + /// True when the bundle on disk is not the version that is running (the updater replaced it). + static func bundleWasUpdated() -> Bool { + let running = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + let plist = Bundle.main.bundleURL.appendingPathComponent("Contents/Info.plist") + let onDisk = (NSDictionary(contentsOf: plist) as? [String: Any])?["CFBundleShortVersionString"] as? String + return running != nil && onDisk != nil && running != onDisk + } + /// Restarts into the freshly installed bundle (same path), reopening every folder in the same /// window layout: tabs of one window stay together, separate windows stay separate. func relaunch() { @@ -504,7 +518,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard let exe = Bundle.main.url(forResource: "codeviewer", withExtension: nil)?.path else { return } let target = "/usr/local/bin/codeviewer" let quoted = { (s: String) in "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" } - let shell = "mkdir -p /usr/local/bin && ln -sf \(quoted(exe)) \(quoted(target))" + let shell = "mkdir -p /usr/local/bin && ln -sfh \(quoted(exe)) \(quoted(target))" let source = "do shell script \"\(shell.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\""))\" with administrator privileges" var error: NSDictionary? NSAppleScript(source: source)?.executeAndReturnError(&error) diff --git a/scripts/build.ts b/scripts/build.ts index df71af8..13bcf1d 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -92,7 +92,14 @@ function minifyCss(css: string): string { async function compile(target: string | null, outfile: string) { await mkdir(path.dirname(outfile), { recursive: true }); - const cmd = ["bun", "build", "--compile", "--minify", "--sourcemap=none", path.join(root, "src/server/main.ts"), path.join(root, "src/server/search.worker.ts"), "--outfile", outfile]; + // No autoloading from the working directory, which is often the folder being viewed: a + // bunfig.toml there could preload its own code into the server, and a .env could set variables + // for every shell and git process (LD_PRELOAD, PROMPT_COMMAND). + const cmd = [ + "bun", "build", "--compile", "--minify", "--sourcemap=none", + "--no-compile-autoload-bunfig", "--no-compile-autoload-dotenv", "--no-compile-autoload-tsconfig", "--no-compile-autoload-package-json", + path.join(root, "src/server/main.ts"), path.join(root, "src/server/search.worker.ts"), "--outfile", outfile, + ]; if (target) cmd.push(`--target=${target}`); await sh(cmd); log(`binary ${path.relative(root, outfile)} ${mb((await stat(outfile)).size)}`); diff --git a/src/client/api.ts b/src/client/api.ts index fb2a560..604663e 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -1,3 +1,5 @@ +import { imageRefusal } from "./imagecheck.ts"; + export interface Entry { name: string; type: "dir" | "file" | "link-broken"; @@ -122,6 +124,16 @@ async function check(res: Response): Promise { const check_ = (res: Response) => check(res); +/** The native Mac window (WKWebView with the app's message handler). */ +const IN_MAC_APP = !!(globalThis as unknown as { webkit?: { messageHandlers?: { native?: unknown } } }).webkit?.messageHandlers?.native; + +/** Base64 of bytes, in chunks (String.fromCharCode with one huge spread overflows the stack). */ +export function base64(bytes: Uint8Array): string { + let bin = ""; + for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + return btoa(bin); +} + export const api = { info: async (): Promise => (await check(await fetch("/api/info"))).json(), list: async (path: string): Promise => (await check(await fetch(`/api/list?path=${q(path)}`))).json(), @@ -153,8 +165,9 @@ export const api = { }, /** Changed files vs HEAD or vs the base branch (see src/server/git.ts Changes). */ gitChanges: async (scope: "head" | "branch"): Promise => (await check(await fetch(`/api/git/changes?scope=${scope}`))).json(), - gitBlame: async (path: string, line: number) => { - const r = await fetch(`/api/git/blame?path=${q(path)}&line=${line}`); + gitBlame: async (path: string, line: number, signal?: AbortSignal) => { + const r = await fetch(`/api/git/blame?path=${q(path)}&line=${line}`, { signal }).catch(() => null); + if (!r) return null; // aborted (the cursor moved on) or offline return r.ok ? ((await r.json()) as { hash: string; author: string; time: number; summary: string; you: boolean; uncommitted: boolean }) : null; @@ -175,8 +188,17 @@ export const api = { * Raw file as a blob: URL (images can't send the auth header themselves). Caller revokes it. * With `max`, a file larger than that many bytes is refused by the server (413) instead. */ - rawBlobUrl: async (path: string, max?: number): Promise => - URL.createObjectURL(await (await check(await fetch(`/api/raw?path=${q(path)}${max ? `&max=${max}` : ""}`))).blob()), + rawBlobUrl: async (path: string, max?: number): Promise => { + const blob = await (await check(await fetch(`/api/raw?path=${q(path)}${max ? `&max=${max}` : ""}`))).blob(); + // Decompression and bombs are refused before the browser decodes anything. + const refusal = imageRefusal(new Uint8Array(await blob.arrayBuffer()), blob.type); + if (refusal) throw new Error(refusal); + // An SVG is a document: opened from a blob: URL ("Open image in new tab") its script runs in + // this page's origin. A data: URL draws the same, and a data: document has an opaque origin. + // The Mac window cancels blob: navigations already, and its content rules admit only blob:. + if (blob.type === "image/svg+xml" && !IN_MAC_APP) return `data:image/svg+xml;base64,${base64(new Uint8Array(await blob.arrayBuffer()))}`; + return URL.createObjectURL(blob); + }, /** Streams search events; ends quietly on abort, throws the server's error message on failure. */ async *search(params: SearchParams, signal?: AbortSignal): AsyncGenerator { const sp = new URLSearchParams({ q: params.q }); diff --git a/src/client/app.ts b/src/client/app.ts index ae68b79..c53b198 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -10,7 +10,7 @@ import { icons } from "./icons.ts"; import { nativeThemeMessage, setDocumentTheme, type ThemeName } from "./theme.ts"; import { GitState, relativeTime } from "./git.ts"; import { Updates } from "./updates.ts"; -import { basename, debounce, dragSplitter, esc, formatBytes, h, IS_MAC, key, MOD, store } from "./util.ts"; +import { basename, debounce, dragSplitter, esc, formatBytes, h, IS_MAC, key, MOD, showHidden, store } from "./util.ts"; import { Help } from "./help.ts"; import { isContextMenuOpen } from "./menu.ts"; import { SearchView } from "./search.ts"; @@ -104,6 +104,8 @@ class App { async start() { await store.init(); + // A layout change in the last 200 ms before closing would miss the save on close otherwise. + store.beforeSave(() => store.set("layout", { ...this.L, v: LAYOUT_VERSION })); const saved = store.get & { v?: number }>("layout", {}); // Older layouts carried pre-panel font sizes; let the new defaults apply. if (saved.v !== LAYOUT_VERSION) { @@ -518,7 +520,7 @@ class App { if (!list?.length) return this.notify(`No history for ${basename(path)}`); const items: PickItem[] = list.map((c) => ({ label: c.subject || "(no subject)", - detail: `${c.oid.slice(0, 7)} · ${c.author} · ${relativeTime(c.time)}${c.oldPath && c.oldPath !== c.path ? ` · from ${c.oldPath}` : ""}`, + detail: `${c.oid.slice(0, 7)} · ${showHidden(c.author)} · ${relativeTime(c.time)}${c.oldPath && c.oldPath !== c.path ? ` · from ${showHidden(c.oldPath)}` : ""}`, icon: `${c.status}`, run: () => void this.editor.openCommitDiff(c, { focus: true }), })); @@ -528,7 +530,7 @@ class App { private copyReference(withCode: boolean) { const r = this.editor.referenceTarget(); if (typeof r === "string") return this.notify(r); - if (unsafePath(r.path)) return this.notify("This file name has control characters: no reference copied"); + if (unsafePath(r.path)) return this.notify("This file name has control or shell command characters: no reference copied"); const text = withCode && r.span && r.lines ? referenceWithCode(r.path, r.span, r.lines) : reference(r.path, r.span); void navigator.clipboard.writeText(text); this.notify(`Copied ${reference(r.path, r.span)}${withCode && r.span ? " with code" : ""}`); @@ -538,7 +540,7 @@ class App { private referenceToTerminal() { const r = this.editor.referenceTarget(); if (typeof r === "string") return this.notify(r); - if (unsafePath(r.path)) return this.notify("This file name has control characters: nothing inserted"); + if (unsafePath(r.path)) return this.notify("This file name has control or shell command characters: nothing inserted"); const term = this.terms.active; if (!term || this.terms.activeExited) return this.notify("No running terminal to insert into"); if (!this.L.panel) this.togglePanel(true); @@ -688,7 +690,7 @@ class App { this.lastStatus = s; // The text sits in its own span so a long path gets an ellipsis (flex items don't ellipsize bare text). const item = (text: string, title = "") => h("span", { class: "st-item", title }, h("span", { class: "st-text" }, text)); - const left: HTMLElement[] = [h("span", { class: "st-item st-root", title: this.info.root }, h("span", { class: "st-dot live" }), this.info.name)]; + const left: HTMLElement[] = [h("span", { class: "st-item st-root", title: showHidden(this.info.root) }, h("span", { class: "st-dot live" }), showHidden(this.info.name))]; const g = this.git.status; if (g.repo) { const name = g.branch ?? (g.oid ? g.oid.slice(0, 7) : "no commits"); @@ -698,8 +700,8 @@ class App { g.head ? `last commit: ${g.head.subject} (${g.head.author}, ${relativeTime(g.head.time)})` : "", ].filter(Boolean).join("\n"); const dirty = this.git.counts.changed + this.git.counts.untracked + this.git.counts.conflicts > 0; - const branch = h("button", { class: "st-item st-git", title: tip, onclick: () => this.showChanges() }); - branch.innerHTML = `${icons.branch}${esc(name)}${dirty ? "*" : ""}`; + const branch = h("button", { class: "st-item st-git", title: tip.split("\n").map(showHidden).join("\n"), onclick: () => this.showChanges() }); + branch.innerHTML = `${icons.branch}${esc(showHidden(name))}${dirty ? "*" : ""}`; if (g.behind || g.ahead) { branch.insertAdjacentHTML("beforeend", `${g.behind ? `↓${g.behind}` : ""}${g.ahead ? `↑${g.ahead}` : ""}`); } @@ -744,7 +746,7 @@ class App { const u = this.updates.info; if (u?.available) { const b = h("button", { class: "st-item st-update", title: `Codeviewer ${u.latest} is available (you have ${u.current})`, onclick: () => this.updates.open() }); - b.innerHTML = `↑Update ${u.latest}`; + b.innerHTML = `↑Update ${esc(u.latest ?? "")}`; // validated by the server too right.push(b); } right.push(h("span", { class: "st-item muted" }, "Read-only")); @@ -782,7 +784,7 @@ class App { else if (mod && e.shiftKey && k === "e") { if (!this.L.sidebar) this.toggleSidebar(true); this.tree.focus(); - } else if (mod && k === "b" && !inTerm) this.toggleSidebar(); + } else if (mod && k === "b" && appKey) this.toggleSidebar(); else if (mod && !e.shiftKey && k === "f" && !inTerm) this.editor.openFind(); else if (e.ctrlKey && k === "g" && !inTerm) this.palette.open(":"); else if (mod && k === "w" && !inTerm) this.editor.close(); @@ -807,7 +809,7 @@ class App { else if (mod && (k === "=" || k === "+") && appKey) this.zoom(1); else if (mod && k === "-" && appKey) this.zoom(-1); else if (mod && k === "0" && appKey) this.zoom(0); - else if (mod && k === "j" && !inTerm) this.togglePanel(); + else if (mod && k === "j" && appKey) this.togglePanel(); else if (helpKey(e, { isMac: IS_MAC }) && appKey) this.help.open(); else if (e.key === "Escape" && this.help.isOpen) this.help.close(); else if (e.key === "Escape" && this.palette.isOpen) this.palette.close(); @@ -827,7 +829,16 @@ class App { const ws = new WebSocket(wsUrl("/ws/events")); ws.onopen = () => { retry = 500; - if (document.body.classList.contains("offline")) this.notify("Reconnected to codeviewer"); + if (document.body.classList.contains("offline")) { + this.notify("Reconnected to codeviewer"); + // Events sent while disconnected are gone: refresh everything they would have touched. + void api.gitStatus().then((s) => this.git.set(s), () => {}); + this.palette.invalidate(); + this.search.markStale([], true); + void this.tree.reloadAll(); + this.editor.onChanged(this.editor.tabs.map((t) => t.path)); + this.changes.onGitChanged(); + } document.body.classList.remove("offline"); }; ws.onmessage = (ev) => { @@ -837,6 +848,9 @@ class App { if (msg.t === "changed" && msg.paths) { if (msg.structural) this.palette.invalidate(); this.search.markStale(msg.paths, msg.many); + // A second edit to an already modified file changes no status, so no git event follows: + // the Changes counts would stay stale (it debounces its own refresh). + this.changes.onGitChanged(); if (msg.many) { void this.tree.reloadAll(); this.editor.onChanged(this.editor.tabs.map((t) => t.path)); diff --git a/src/client/codeview.ts b/src/client/codeview.ts index 44958a8..ec0d763 100644 --- a/src/client/codeview.ts +++ b/src/client/codeview.ts @@ -36,6 +36,31 @@ export function markRanges(n: number, ranges: readonly MarkRange[]): Uint8Array return out; } +/** + * Trojan Source: each bidi override/isolate is wrapped in a zero-width inline-block (`.bidi`, drawn + * as a bar, the code point in its title). An atomic inline reorders nothing around it, so the line + * displays in logical order, while the character stays in the DOM: selection offsets and copy are + * unchanged. Runs on already-escaped HTML (esc() leaves these characters alone). + */ +export function markBidi(html: string, budget: { left: number } = { left: MAX_BIDI_MARKS }): string { + // One marker per run of controls, and at most MAX_BIDI_MARKS per line: a marker per character + // made 248 000 elements for a file of U+202E lines (seconds of layout, gigabytes of memory). + // Past the budget a line shows the code points as escapes instead (its offsets shift; such a + // line is hostile anyway), never the raw characters, which would reorder it again. + return html.replace(/[\u202a-\u202e\u2066-\u2069]+/g, (run) => { + if (budget.left <= 0) return [...run].map((c) => `\\u${c.charCodeAt(0).toString(16)}`).join(""); + budget.left--; + const cps = [...run].map((c) => `U+${c.charCodeAt(0).toString(16).toUpperCase()}`).join(" "); + return `${run}`; + }); +} + +/** Bidi markers per rendered line (see markBidi). */ +export const MAX_BIDI_MARKS = 32; +/** Rows and columns the HEAD peek shows at most (it is not virtualized). */ +const PEEK_ROWS = 500; +const PEEK_COLS = 4000; + /** New scrollLeft that brings 0-based column `col` into view, or `scrollLeft` when it already is. */ export function revealCol(scrollLeft: number, clientWidth: number, gutterW: number, charW: number, col: number): number { const x = gutterW + Math.min(col, MAX_RENDER_COLS) * charW; @@ -139,6 +164,7 @@ export class CodeView { new ResizeObserver(() => this.schedule()).observe(this.scroller); this.findInput.addEventListener("input", () => this.runFind()); this.findInput.addEventListener("keydown", (e) => { + if (e.isComposing || e.keyCode === 229) return; // the IME's own Enter / Escape if (e.key === "Enter") { e.preventDefault(); this.nextMatch(e.shiftKey ? -1 : 1); @@ -508,8 +534,14 @@ export class CodeView { removed.length && addedN ? `Changed: ${removed.length} line${removed.length === 1 ? "" : "s"} in HEAD → ${addedN} now` : removed.length ? `Deleted: ${removed.length} line${removed.length === 1 ? "" : "s"} from HEAD` : `Added: ${addedN} new line${addedN === 1 ? "" : "s"}`; + // Not virtualized: at most PEEK_ROWS rows of PEEK_COLS columns (a whole 8 MB file of changed + // lines built 1.6 million rows synchronously and exhausted memory). + const shown = removed.slice(0, PEEK_ROWS); + const more = removed.length - shown.length; const body = removed.length - ? `
${removed.map((l, i) => `
${hk.a0 + i + 1}${esc(l) || " "}
`).join("")}
` + ? `
${shown.map((l, i) => `
${hk.a0 + i + 1}${markBidi(esc(l.slice(0, PEEK_COLS))) || " "}
`).join("")}` + + (more > 0 ? `
… ${more.toLocaleString()} more lines
` : "") + + `
` : ""; const box = h("div", { class: "peek", role: "dialog", "aria-label": title }, h("div", { class: "peek-head" }, h("span", { class: "peek-badge" }, "HEAD"), h("span", null, title), @@ -640,9 +672,10 @@ export class CodeView { } let out = ""; let s = 0; + const bidi = { left: MAX_BIDI_MARKS }; for (let p = 1; p <= n; p++) { if (p === n || cls[p] !== cls[s] || (mark && mark[p] !== mark[s])) { - const seg = esc(text.slice(s, p)); + const seg = markBidi(esc(text.slice(s, p)), bidi); const t = cls[s] ? TOKS[cls[s]! - 1] : ""; const m = mark?.[s]; let piece = t ? `${seg}` : seg; diff --git a/src/client/diff.ts b/src/client/diff.ts index ccb7781..4f357f2 100644 --- a/src/client/diff.ts +++ b/src/client/diff.ts @@ -11,6 +11,8 @@ export interface Hunk { } const MAX_D = 2000; +/** Lines of changed middle section past which the diff is one replaced hunk without interning. */ +export const MAX_DIFF_LINES = 200_000; export function diffLines(a: string[], b: string[]): Hunk[] { let s = 0; @@ -21,6 +23,9 @@ export function diffLines(a: string[], b: string[]): Hunk[] { ea--; eb--; } + // A huge rewritten middle (a 6 MB file of distinct lines took 2.4 s just to intern) could not + // come out as anything but one replaced hunk within MAX_D anyway: say so directly. + if (ea - s + (eb - s) > MAX_DIFF_LINES) return [{ a0: s, a1: ea, b0: s, b1: eb }]; const ids = new Map(); const id = (x: string) => { let v = ids.get(x); diff --git a/src/client/diffview.ts b/src/client/diffview.ts index 8feb6af..b0b4295 100644 --- a/src/client/diffview.ts +++ b/src/client/diffview.ts @@ -1,7 +1,7 @@ import { diffLines, type Hunk } from "./diff.ts"; import { Highlighter, type Tok } from "./highlight.ts"; import { icons } from "./icons.ts"; -import { basename, dirname, esc, h, showHidden } from "./util.ts"; +import { basename, dirname, esc, h, showHidden, showHiddenText } from "./util.ts"; import type { Letter } from "./git.ts"; const TOKS: Tok[] = ["k", "s", "c", "n", "f", "t", "p", "o", "v", "m", "h", "a", "x", "b", "i", "l"]; @@ -70,7 +70,7 @@ export function spansHtml(text: string, spans: readonly [number, number, Tok][]) let s = 0; for (let p = 1; p <= n; p++) { if (p === n || cls[p] !== cls[s]) { - const seg = esc(showHidden(text.slice(s, p))); + const seg = esc(showHiddenText(text.slice(s, p))); const t = cls[s] ? TOKS[cls[s]! - 1] : ""; out += t ? `${seg}` : seg; s = p; diff --git a/src/client/editor.ts b/src/client/editor.ts index 7a4da1d..3bdc224 100644 --- a/src/client/editor.ts +++ b/src/client/editor.ts @@ -8,7 +8,7 @@ import { isMarkdown } from "./markdown.ts"; import { MarkdownView } from "./mdview.ts"; import { decidePreview, tooLargeNote } from "./mdgate.ts"; import { planView, toggleLabel, toggleMode, viewMode } from "./viewstate.ts"; -import { basename, debounce, dirname, esc, formatBytes, h, MOD } from "./util.ts"; +import { basename, debounce, dirname, esc, formatBytes, h, MOD, showHidden } from "./util.ts"; import { GitState, relativeTime } from "./git.ts"; import { hasSymbols, symbols, type Sym } from "./symbols.ts"; import { lineSpan, type Span } from "./reference.ts"; @@ -133,18 +133,21 @@ export class Editor { const i = this.active ? this.tabs.indexOf(this.active) + 1 : this.tabs.length; this.tabs.splice(i, 0, tab); } + // load() applies the line. Set before the await: a second open() of the same file while this + // one loads (another search result) replaces it, and must not be undone when this resumes. + if (opts.line) tab.goto = { line: opts.line, col: opts.col, len: opts.len }; this.activate(tab); await this.load(tab); } else { if (opts.pin) tab.pinned = true; this.activate(tab); if (opts.line && tab.md) await this.toSource(tab); + if (opts.line && tab.code) { + if (opts.col !== undefined && opts.len) tab.code.showRange(opts.line - 1, opts.col, opts.col + opts.len); + else tab.code.revealLine(opts.line - 1); + } else if (opts.line && !tab.md && !tab.image) tab.goto = { line: opts.line, col: opts.col, len: opts.len }; // still loading } - if (opts.line && tab.code) { - if (opts.col !== undefined && opts.len) tab.code.showRange(opts.line - 1, opts.col, opts.col + opts.len); - else tab.code.revealLine(opts.line - 1); - } else if (opts.line && !tab.md && !tab.image) tab.goto = { line: opts.line, col: opts.col, len: opts.len }; // still loading - if (opts.focus !== false) tab.code?.focus(); + if (opts.focus !== false && this.active === tab) tab.code?.focus(); this.render(); } @@ -352,6 +355,7 @@ export class Editor { tab.md = undefined; } if (!plan.code) this.dropCode(tab); + if (f.kind !== "image") this.dropImage(tab); // an image reload keeps its view (and zoom) if (f.kind === "text") { tab.eol = /\r\n/.test(f.text.slice(0, 10000)) ? "CRLF" : "LF"; if (plan.view === "md") { @@ -400,6 +404,7 @@ export class Editor { if (tab.image && reload) tab.image.reload(tab.mtime); else { this.dropCode(tab); + this.dropImage(tab); tab.image = new ImageView(tab.path, tab.mtime, f.size, () => this.status()); tab.el.replaceChildren(tab.image.el); if (tab.path.toLowerCase().endsWith(".svg")) tab.el.prepend(this.viewToggle(tab, "View source", icons.code)); @@ -447,7 +452,7 @@ export class Editor { tab.forcePreview = next.forcePreview; // an explicit request overrides the size gate } else tab.asSource = !tab.asSource; // SVG: image or source this.dropCode(tab); - tab.image = undefined; + this.dropImage(tab); tab.md?.dispose(); tab.md = undefined; void this.load(tab); @@ -523,9 +528,16 @@ export class Editor { tab.code = undefined; } + /** Releases an image view (its blob: URL holds the file's bytes until revoked). */ + private dropImage(tab: Tab) { + tab.image?.dispose(); + tab.image = undefined; + } + private disposeTab(tab: Tab) { tab.gen++; // a load still in flight must not build a view for a closed tab tab.code?.dispose(); + tab.image?.dispose(); tab.diff?.dispose(); tab.md?.dispose(); tab.el.remove(); @@ -570,19 +582,31 @@ export class Editor { else if (g?.xy[0] === "A") code.setGit("", false); // staged new file: HEAD has nothing else { const base = await api.gitBase(tab.path); - if (gen !== tab.gen || tab.code !== code) return; + if (gen !== tab.gen || tab.code !== code) { + // Discarded (a reload overtook it): forget the key, or the reload's own refresh skips + // and the gutter keeps the old HEAD base. + if (tab.gitKey === key) tab.gitKey = ""; + return; + } code.setGit(base, false); } if (tab === this.active) this.status(); } + private blameAbort: AbortController | null = null; + private async loadBlame(tab: Tab, code: CodeView, line: number) { const g = this.git?.repo ? this.git.file(tab.path) : null; if (!this.git?.repo || line < 0 || g?.letter === "U" || g?.letter === "I" || g?.letter === "A") return; - const b = await api.gitBlame(tab.path, line + 1); + // One blame at a time: the previous request is aborted, which kills its git process (a git + // blame on a deep history takes seconds, and every cursor move used to start another). + this.blameAbort?.abort(); + const ac = (this.blameAbort = new AbortController()); + const b = await api.gitBlame(tab.path, line + 1, ac.signal); + if (this.blameAbort === ac) this.blameAbort = null; if (!b || tab.code !== code || code.activeLine !== line) return; - const who = b.you ? "You" : esc(b.author); - const html = b.uncommitted ? "You · uncommitted change" : `${who}, ${relativeTime(b.time)} · ${esc(b.summary)}`; + const who = b.you ? "You" : esc(showHidden(b.author)); + const html = b.uncommitted ? "You · uncommitted change" : `${who}, ${relativeTime(b.time)} · ${esc(showHidden(b.summary))}`; code.setBlame(line, html); } @@ -708,11 +732,11 @@ export class Editor { const g = t.isDiff && t.file ? { letter: letterOf(t.file.status), label: labelOf(t.file) } : this.git?.repo ? this.git.file(t.path) : null; if (g) cls.push(`git-${g.letter}`); t.tabEl.className = cls.join(" "); - t.tabEl.title = t.commit + t.tabEl.title = showHidden(t.commit ? `${t.path} · ${t.commit.oid.slice(0, 7)} ${t.commit.subject} (${t.commit.author}, ${relativeTime(t.commit.time)})` - : g ? `${t.path} · ${g.label}${t.isDiff ? ` · diff ${this.diffContext.vs}` : ""}` : t.path; + : g ? `${t.path} · ${g.label}${t.isDiff ? ` · diff ${this.diffContext.vs}` : ""}` : t.path); t.tabEl.setAttribute("aria-selected", String(t === this.active)); - t.tabEl.innerHTML = `${fileIcon(t.path)}${esc(basename(t.path))}${hint(t.path) ? `${esc(hint(t.path))}` : ""}${t.commit ? `${t.commit.oid.slice(0, 7)}` : t.isDiff ? 'diff' : ""}`; + t.tabEl.innerHTML = `${fileIcon(t.path)}${esc(showHidden(basename(t.path)))}${hint(t.path) ? `${esc(showHidden(hint(t.path)))}` : ""}${t.commit ? `${t.commit.oid.slice(0, 7)}` : t.isDiff ? 'diff' : ""}`; t.tabEl.querySelector(".tab-close")!.addEventListener("click", (e) => { e.stopPropagation(); this.close(t); @@ -727,12 +751,12 @@ export class Editor { const full = parts.slice(0, i + 1).join("/"); const isLast = i === parts.length - 1; this.crumbs.append( - h("button", { class: `crumb${isLast ? " last" : ""}`, onclick: () => this.onReveal(full), title: full }, p), + h("button", { class: `crumb${isLast ? " last" : ""}`, onclick: () => this.onReveal(full), title: showHidden(full) }, showHidden(p)), ); if (!isLast) this.crumbs.append(h("span", { class: "crumb-sep" }, "›")); }); const c = this.active.commit; - if (c) this.crumbs.append(h("span", { class: "pill", title: `${c.oid}\n${c.author}, ${relativeTime(c.time)}` }, `${c.oid.slice(0, 7)} · ${c.subject}`)); + if (c) this.crumbs.append(h("span", { class: "pill", title: `${c.oid}\n${showHidden(c.author)}, ${relativeTime(c.time)}` }, `${c.oid.slice(0, 7)} · ${showHidden(c.subject)}`)); else if (this.active.isDiff) this.crumbs.append(h("span", { class: "pill" }, `diff ${this.diffContext.vs}`)); if (this.active.truncated) this.crumbs.append(h("span", { class: "pill warn" }, `showing first ${formatBytes(8 * 1024 * 1024)}`)); if (this.active.deleted) this.crumbs.append(h("span", { class: "pill warn" }, "deleted on disk")); @@ -786,29 +810,43 @@ class ImageView { }, { passive: false }, ); - new ResizeObserver(() => this.mode === "fit" && this.apply()).observe(this.stage); + this.ro = new ResizeObserver(() => this.mode === "fit" && this.apply()); + this.ro.observe(this.stage); this.reload(mtime); } + private ro: ResizeObserver; + private disposed = false; + + /** Releases the image's bytes (the blob: URL holds them until revoked) and the observer. */ + dispose() { + this.disposed = true; + this.ro.disconnect(); + if (this.blobUrl) URL.revokeObjectURL(this.blobUrl); + this.blobUrl = ""; + } + private blobUrl = ""; private failed = false; /** The window cannot decode the file (damaged, or TIFF/HEIC outside WebKit): show its bytes. */ - private async undecodable() { + private async undecodable(reason?: string) { if (this.failed) return; this.failed = true; - const note = h("div", { class: "notice error" }, "This image could not be displayed: the file is damaged, or this window cannot decode its format. Its first bytes:"); + const note = h("div", { class: "notice error" }, `${reason ?? "This image could not be displayed: the file is damaged, or this window cannot decode its format."} Its first bytes:`); this.el.replaceChildren(note, await binaryView(this.path, this.size)); } reload(_mtime: number) { void api.rawBlobUrl(this.path).then( (u) => { + // Closed while it loaded: nothing will show it. + if (this.disposed) return URL.revokeObjectURL(u); if (this.blobUrl) URL.revokeObjectURL(this.blobUrl); this.blobUrl = u; this.img.src = u; }, - () => void this.undecodable(), + (e: unknown) => void this.undecodable(e instanceof Error && /too large|too many times/.test(e.message) ? e.message : undefined), ); } diff --git a/src/client/highlight.ts b/src/client/highlight.ts index 1728ada..2313ca6 100644 --- a/src/client/highlight.ts +++ b/src/client/highlight.ts @@ -264,6 +264,9 @@ const NAMES: Record = { "cmakelists.txt": "make", gemfile: "rb", rakefile: "rb", ".editorconfig": "toml", ".npmrc": "toml", }; +/** Every language id lexLine knows (tests run each against pathological input). */ +export const LANG_IDS: readonly string[] = Object.keys(LANGS); + export function languageFor(path: string): string { const base = path.slice(path.lastIndexOf("/") + 1).toLowerCase(); if (NAMES[base]) return NAMES[base]!; @@ -296,6 +299,8 @@ export function lexLine(langId: string, text: string, state: number, out: Span[] const n = text.length; let i = 0; + /** CSS: the line's last ";" and "{" (computed once, for property detection). */ + let cssLast: { semi: number; brace: number } | null = null; // Continue a construct from the previous line. if (state > 0 && state < 100 && L.block) { @@ -321,7 +326,10 @@ export function lexLine(langId: string, text: string, state: number, out: Span[] } if (L.special === "yaml") { - const m = /^(\s*-?\s*)([^\s#:'"][^#:]*?|"[^"]*"|'[^']*')(\s*:)(?=\s|$)/.exec(text.slice(i)); + // The key may not end in whitespace (`[^#:]*[^#:\s]`), so `\s*:` never competes with it for the + // same spaces, and the indent is `\s*(?:-\s*)?` (not `\s*-?\s*`): a line of spaces was quadratic + // (171 ms for 10 000). + const m = /^(\s*(?:-\s*)?)([^\s#:'"](?:[^#:]*[^#:\s])?|"[^"]*"|'[^']*')(\s*:)(?=\s|$)/.exec(text.slice(i)); if (m && i === 0) { out.push([m[1]!.length, m[1]!.length + m[2]!.length, "p"]); i = m[0].length; @@ -436,17 +444,24 @@ export function lexLine(langId: string, text: string, state: number, out: Span[] const r = CSS_IDENT.exec(text); if (r) { const end = i + r[0].length; - const after = text.slice(end).trimStart(); - // property: value; (but not a pseudo-class like a:hover {) - if (after.startsWith(":") && /;|^\s*:[^{]*$/.test(after)) out.push([i, end, "p"]); + // The next non-space character, found without copying the rest of the line: slicing and + // regex-testing that rest for every identifier was quadratic (`a::a::…`, 64 KB: 650 ms). + let k = end; + while (k < n && /\s/.test(text[k]!)) k++; + // property: value; (but not a pseudo-class like a:hover {): a ";" somewhere after the + // colon, or no "{" after it. The line's last ";" and "{" answer both. + cssLast ??= { semi: text.lastIndexOf(";"), brace: text.lastIndexOf("{") }; + if (text[k] === ":" && (cssLast.semi > k || cssLast.brace < k)) out.push([i, end, "p"]); else if (r[0].startsWith("--")) out.push([i, end, "v"]); - else if (after.startsWith("(")) out.push([i, end, "f"]); + else if (text[k] === "(") out.push([i, end, "f"]); i = end; continue; } } if (L.dollarVars && c === 36 /* $ */) { - const m = /\$(?:\{[^}]*\}|\(|[\w@#?*!$-]+)/y; + // `${…}` stops at the next "{" and after 256 characters: scanning to the line's end for + // every unclosed `${` was quadratic. + const m = /\$(?:\{[^{}]{0,256}\}|\(|[\w@#?*!$-]+)/y; m.lastIndex = i; const r = m.exec(text); if (r) { @@ -506,6 +521,9 @@ export function lexLine(langId: string, text: string, state: number, out: Span[] return 0; } +/** Longest JS regex literal recognised as one (longer ones lex as operators and text). */ +const MAX_REGEX_LITERAL = 512; + const REGEX_AFTER_WORD = new Set(["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "yield", "await", "instanceof"]); /** @@ -515,14 +533,24 @@ const REGEX_AFTER_WORD = new Set(["return", "typeof", "case", "do", "else", "in" function regexLiteralEnd(text: string, i: number): number { const next = text[i + 1]; if (next === "/" || next === "*") return 0; - const before = text.slice(0, i).trimEnd(); - const last = before[before.length - 1]; + // Scanned backwards from `i`, never by slicing the prefix: a regex anchored at `$` over the whole + // prefix, per "/", made one long line take seconds (cubic in the line length). + let k = i - 1; + while (k >= 0 && /\s/.test(text[k]!)) k--; + const last = k >= 0 ? text[k] : undefined; if (last !== undefined && !"(,=:[!&|?{};+-*%<>~^".includes(last)) { - const word = /[A-Za-z_$][\w$]*$/.exec(before)?.[0]; + // The word before it ([A-Za-z_$][\w$]*); no keyword in REGEX_AFTER_WORD is longer than 10. + let w = k; + while (w >= 0 && k - w <= 11 && /[\w$]/.test(text[w]!)) w--; + if (w >= 0 && k - w > 11 && /[\w$]/.test(text[w]!)) return 0; // a long identifier: division + let word = text.slice(w + 1, k + 1); + word = word.replace(/^\d+/, ""); if (!word || !REGEX_AFTER_WORD.has(word)) return 0; // division: a / b, x) / 2, arr[i] / n } let inClass = false; - for (let j = i + 1; j < text.length; j++) { + // A regex literal ends within a few hundred characters; scanning to the end of the line for + // every unterminated "/" was quadratic. + for (let j = i + 1, stop = Math.min(text.length, i + 1 + MAX_REGEX_LITERAL); j < stop; j++) { const ch = text[j]; if (ch === "\\") j++; else if (ch === "[") inClass = true; @@ -657,6 +685,15 @@ function lexMarkdown(text: string, state: number, out: Span[]): number { * Per-document highlighter: caches the lexer state at each line start and lazily * advances it, so the cost of highlighting line N is paid once. */ +/** + * Columns of a line that are lexed: views colour at most 1000 (codeview HIGHLIGHT_COLS) or 4000, + * and some scans are quadratic in the line (an unterminated regex per "/", `${`, `[`): lexing a + * 10 000-character line in full took seconds for 100 crafted lines. A construct that opens past + * this column (a block comment in a minified line) does not carry to the next line. + */ +export const LEX_COLS = 2000; +const lexed = (text: string) => (text.length > LEX_COLS ? text.slice(0, LEX_COLS) : text); + export class Highlighter { private states: Int32Array; private known = 1; // states[0..known) are valid @@ -677,7 +714,7 @@ export class Highlighter { const scratch: Span[] = []; for (let i = this.known - 1; i < idx; i++) { scratch.length = 0; - this.states[i + 1] = lines[i]!.length > 10_000 ? 0 : lexLine(this.lang, lines[i]!, this.states[i]!, scratch); + this.states[i + 1] = lines[i]!.length > 10_000 ? 0 : lexLine(this.lang, lexed(lines[i]!), this.states[i]!, scratch); } this.known = idx + 1; } @@ -685,7 +722,7 @@ export class Highlighter { out.length = 0; const text = lines[idx]!; if (text.length > 10_000) return []; - lexLine(this.lang, text, this.states[idx]!, out); + lexLine(this.lang, lexed(text), this.states[idx]!, out); return out.slice().sort((a, b) => a[0] - b[0]); } } diff --git a/src/client/imagecheck.ts b/src/client/imagecheck.ts new file mode 100644 index 0000000..ffc8cd3 --- /dev/null +++ b/src/client/imagecheck.ts @@ -0,0 +1,112 @@ +// Checks run on an image's bytes before the browser decodes or renders it. Repository images are +// untrusted: a 389 KB PNG declared 20000×20000 pixels (2 GB decoded), and a 1.6 KB SVG of nested +// references expanded to a million elements and hung the page. Pure functions (tested). + +/** Most pixels an image may declare (about 200 MB decoded). */ +export const MAX_PIXELS = 50_000_000; +/** Most elements an SVG's references may expand to. */ +export const MAX_SVG_USES = 10_000; + +/** Width and height from a PNG, GIF, JPEG, WebP or BMP header; null when unknown or unreadable. */ +export function imageSize(b: Uint8Array): { w: number; h: number } | null { + const u32be = (o: number) => ((b[o]! << 24) >>> 0) + (b[o + 1]! << 16) + (b[o + 2]! << 8) + b[o + 3]!; + const u16le = (o: number) => b[o]! | (b[o + 1]! << 8); + const u16be = (o: number) => (b[o]! << 8) | b[o + 1]!; + if (b.length >= 24 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return { w: u32be(16), h: u32be(20) }; // PNG IHDR (also APNG) + if (b.length >= 10 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) return { w: u16le(6), h: u16le(8) }; // GIF logical screen + if (b.length >= 26 && b[0] === 0x42 && b[1] === 0x4d) { + const w = Math.abs(new DataView(b.buffer, b.byteOffset).getInt32(18, true)); + const h = Math.abs(new DataView(b.buffer, b.byteOffset).getInt32(22, true)); + return { w, h }; // BMP (negative height = top-down) + } + if (b.length >= 30 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) { + const kind = String.fromCharCode(b[12]!, b[13]!, b[14]!, b[15]!); + if (kind === "VP8X") return { w: 1 + (b[24]! | (b[25]! << 8) | (b[26]! << 16)), h: 1 + (b[27]! | (b[28]! << 8) | (b[29]! << 16)) }; + if (kind === "VP8L") { + const v = b[21]! | (b[22]! << 8) | (b[23]! << 16) | (b[24]! << 24); + return { w: 1 + (v & 0x3fff), h: 1 + ((v >>> 14) & 0x3fff) }; + } + if (kind === "VP8 ") return { w: u16le(26) & 0x3fff, h: u16le(28) & 0x3fff }; + return null; + } + if (b.length >= 4 && b[0] === 0xff && b[1] === 0xd8) { + // JPEG: walk the segments to the first SOFn. + let o = 2; + while (o + 9 < b.length) { + if (b[o] !== 0xff) return null; + const marker = b[o + 1]!; + if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) { + o += 2; + continue; + } + const len = u16be(o + 2); + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) return { w: u16be(o + 7), h: u16be(o + 5) }; + if (len < 2) return null; + o += 2 + len; + } + return null; + } + return null; +} + +/** + * How many elements the SVG's references expand to (an upper bound): each costs one + * plus the expansion of everything inside the element it references. Cycles and the cap stop the + * walk early. The document is never rendered or executed here; tags are only tokenized. + */ +export function svgUseExpansion(svg: string): number { + const uses = new Map(); // element id → ids its descendant s reference + const all: string[] = []; // every target in the document + const open: { id: string | null }[] = []; + const tag = /<(\/?)([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g; + const attr = (attrs: string, name: RegExp) => name.exec(attrs)?.[1] ?? name.exec(attrs)?.[2] ?? null; + for (let m; (m = tag.exec(svg)); ) { + const [, close, name, attrs = "", selfClose] = m; + if (close) { + open.pop(); + continue; + } + const id = attr(attrs, /\sid\s*=\s*(?:"([^"]*)"|'([^']*)')/); + if (name!.replace(/^.*:/, "") === "use") { + const href = attr(attrs, /\s(?:xlink:)?href\s*=\s*(?:"#([^"]*)"|'#([^']*)')/); + if (href !== null) { + all.push(href); + for (const o of open) if (o.id !== null) (uses.get(o.id) ?? uses.set(o.id, []).get(o.id)!).push(href); + } + } + if (!selfClose) open.push({ id }); + if (all.length > MAX_SVG_USES) return all.length; + } + const memo = new Map(); + const visiting = new Set(); + const expand = (id: string): number => { + const known = memo.get(id); + if (known !== undefined) return known; + if (visiting.has(id)) return MAX_SVG_USES + 1; // a cycle: browsers refuse it, so do we + visiting.add(id); + let n = 0; + for (const t of uses.get(id) ?? []) { + n += 1 + expand(t); + if (n > MAX_SVG_USES) break; + } + visiting.delete(id); + memo.set(id, n); + return n; + }; + let total = 0; + for (const t of all) { + total += 1 + expand(t); + if (total > MAX_SVG_USES) return total; + } + return total; +} + +/** Why an image should not be rendered, or null when it is safe to draw. */ +export function imageRefusal(bytes: Uint8Array, type: string): string | null { + if (type === "image/svg+xml") { + return svgUseExpansion(new TextDecoder().decode(bytes)) > MAX_SVG_USES ? "This SVG references itself too many times to draw safely." : null; + } + const size = imageSize(bytes); + if (size && size.w * size.h > MAX_PIXELS) return `This image is ${size.w.toLocaleString()} × ${size.h.toLocaleString()} pixels, too large to draw safely.`; + return null; +} diff --git a/src/client/markdown.ts b/src/client/markdown.ts index fbb2388..8abf464 100644 --- a/src/client/markdown.ts +++ b/src/client/markdown.ts @@ -876,19 +876,22 @@ function bareUrl(s: string, i: number): string | null { let k = i + m[0].length; while (k < lim && s[k]! > " " && s[k] !== "<") k++; let end = k; + // Parentheses in s[i, end), counted once and kept up to date as the end moves (not recounted per `)`). + let open = 0; + let close = 0; + for (let q = i; q < end; q++) { + if (s[q] === "(") open++; + else if (s[q] === ")") close++; + } for (;;) { const ch = s[end - 1]!; if (end - 1 < i + m[0].length) return null; if ("?!.,:*_~'\";".includes(ch)) end--; else if (ch === ")") { - let open = 0; - let close = 0; - for (let q = i; q < end; q++) { - if (s[q] === "(") open++; - else if (s[q] === ")") close++; - } - if (close > open) end--; - else break; + if (close > open) { + end--; + close--; + } else break; } else break; } return end > i + m[0].length ? s.slice(i, end) : null; diff --git a/src/client/mdrender.ts b/src/client/mdrender.ts index e0e6fae..eac0047 100644 --- a/src/client/mdrender.ts +++ b/src/client/mdrender.ts @@ -1,5 +1,5 @@ -import { languageFor, lexLine, type Span } from "./highlight.ts"; -import { showHidden } from "./util.ts"; +import { languageFor, lexLine, type Span, LEX_COLS } from "./highlight.ts"; +import { showHiddenText } from "./util.ts"; import { classifyImage, MAX_IMAGES } from "./mdimage.ts"; import { resolveRepoPath, @@ -29,7 +29,7 @@ function codeBlock(value: string, lang: string | null, b: Budget): HTMLElement { lines.length > MAX_FENCE_LINES || value.length > MAX_FENCE_CHARS ) { - code.textContent = lines.map(showHidden).join("\n"); + code.textContent = lines.map((l) => showHiddenText(l)).join("\n"); b.take(2); return pre; } @@ -39,18 +39,18 @@ function codeBlock(value: string, lang: string | null, b: Budget): HTMLElement { const line = lines[i]!; if (i) code.append("\n"); spans.length = 0; - state = line.length > 10_000 ? 0 : lexLine(id, line, state, spans); + state = line.length > 10_000 ? 0 : lexLine(id, line.length > LEX_COLS ? line.slice(0, LEX_COLS) : line, state, spans); let at = 0; for (const [s, e, t] of spans) { if (s < at || e <= s) continue; - if (s > at) code.append(showHidden(line.slice(at, s))); + if (s > at) code.append(showHiddenText(line.slice(at, s))); const sp = document.createElement("span"); sp.className = "tok-" + t; - sp.textContent = showHidden(line.slice(s, e)); + sp.textContent = showHiddenText(line.slice(s, e)); code.append(sp); at = e; } - if (at < line.length) code.append(showHidden(line.slice(at))); + if (at < line.length) code.append(showHiddenText(line.slice(at))); if (!b.take(spans.length + 1)) break; } return pre; @@ -113,7 +113,7 @@ function link( } else a = null; } } - if (a && n.title) a.setAttribute("title", showHidden(n.title)); + if (a && n.title) a.setAttribute("title", showHiddenText(n.title)); if (a) parent.append(a); inline(a ?? parent, n.children, b); } @@ -165,7 +165,7 @@ function inline(parent: HTMLElement, nodes: Inline[], b: Budget) { } case "codespan": { const el = document.createElement("code"); - el.textContent = showHidden(n.value); + el.textContent = showHiddenText(n.value); parent.append(el); break; } diff --git a/src/client/palette.ts b/src/client/palette.ts index a04ca68..06bf7c4 100644 --- a/src/client/palette.ts +++ b/src/client/palette.ts @@ -1,6 +1,6 @@ import { api } from "./api.ts"; import { fileIcon, icons } from "./icons.ts"; -import { esc, h } from "./util.ts"; +import { esc, h, showHidden } from "./util.ts"; export interface Command { id: string; @@ -153,6 +153,8 @@ export class Palette { }); this.input.addEventListener("input", () => this.update()); this.input.addEventListener("keydown", (e) => { + // Enter or Escape that confirms or cancels an IME composition belongs to the IME. + if (e.isComposing || e.keyCode === 229) return; if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) this.move(1); else if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "p")) this.move(-1); else if (e.key === "Enter") this.accept(this.sel); @@ -333,9 +335,10 @@ export class Palette { } function highlight(s: string, pos: number[]): string { - if (!pos.length) return esc(s); + // Names are untrusted: controls and bidi overrides show as escapes (positions index the raw string). + if (!pos.length) return esc(showHidden(s)); const set = new Set(pos); let out = ""; - for (let i = 0; i < s.length; i++) out += set.has(i) ? `${esc(s[i]!)}` : esc(s[i]!); + for (let i = 0; i < s.length; i++) out += set.has(i) ? `${esc(showHidden(s[i]!))}` : esc(showHidden(s[i]!)); return out; } diff --git a/src/client/reference.ts b/src/client/reference.ts index 5c571e4..e5305b0 100644 --- a/src/client/reference.ts +++ b/src/client/reference.ts @@ -28,7 +28,9 @@ export function lineSpan(sel: SelRange | null, activeLine: number): Span | null * what follows it. */ export function unsafePath(path: string): boolean { - return /[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/.test(path); + // Also command substitution (`…` and $(…)): pasted at a shell prompt and run, `x$(rm …).md:3` + // would execute it. A plain `$` stays allowed (`$param.tsx` route files expand to nothing). + return /[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069`]|\$\(/.test(path); } export function reference(path: string, span: Span | null): string { @@ -49,6 +51,7 @@ export function referenceWithCode(path: string, span: Span, lines: readonly stri /** Splits `path`, `path:12`, `path:12:5` or `path:12-40` into the path and its first line (0 = none). */ export function parseTarget(t: string): { path: string; line: number } { - const m = /^(.*?)(?::(\d+)(?:-\d+)?)?(?::\d+)?$/.exec(t)!; + // `s`: a link target from terminal output may hold U+2028/U+2029, which `.` does not match. + const m = /^(.*?)(?::(\d+)(?:-\d+)?)?(?::\d+)?$/s.exec(t)!; return { path: m[1]!, line: Number(m[2] ?? 0) }; } diff --git a/src/client/search.ts b/src/client/search.ts index 436cf93..2de5782 100644 --- a/src/client/search.ts +++ b/src/client/search.ts @@ -1,5 +1,5 @@ import { api, type SearchEvent, type SearchMatch } from "./api.ts"; -import { esc, h, showHidden } from "./util.ts"; +import { esc, h, showHidden, showHiddenText } from "./util.ts"; export type { SearchEvent }; @@ -22,6 +22,11 @@ function safe(s: string): string { return esc(showHidden(s)); } +/** Like safe(), for a line of file content: tabs stay tabs. */ +function safeText(s: string): string { + return esc(showHiddenText(s)); +} + /** An include glob for everything under `dir`: Bun.Glob metacharacters, commas (the list separator) and whitespace (trimmed from list entries) backslash-escaped, so the scope is literal. */ export function folderGlob(dir: string): string { return `${dir.replace(/[\\*?[\]{}!,\s]/g, "\\$&")}/**`; @@ -369,7 +374,7 @@ export class SearchView { if (g.collapsed) continue; const pv = m.preview; const on = i === this.sel; - html += `
${m.line}${safe(pv.slice(0, m.pcol))}${safe(pv.slice(m.pcol, m.pcol + m.len))}${safe(pv.slice(m.pcol + m.len))}
`; + html += `
${m.line}${safeText(pv.slice(0, m.pcol))}${safeText(pv.slice(m.pcol, m.pcol + m.len))}${safeText(pv.slice(m.pcol + m.len))}
`; } }); const n = d ? d.matches : total; diff --git a/src/client/styles.css b/src/client/styles.css index dde613e..7eab8ab 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -1234,6 +1234,15 @@ svg { background: transparent; } } +/* A bidi control in code (Trojan Source): zero width, so columns stay put; the bar makes it visible. */ +.bidi { + display: inline-block; + width: 0; + height: 1.1em; + vertical-align: text-bottom; + overflow: visible; + box-shadow: 0 0 0 1px var(--danger); +} .trunc { -webkit-user-select: none; user-select: none; diff --git a/src/client/symbols.ts b/src/client/symbols.ts index 58cf8ac..687304c 100644 --- a/src/client/symbols.ts +++ b/src/client/symbols.ts @@ -108,6 +108,9 @@ function indentOf(s: string): number { return w; } +/** Languages with Go to Symbol patterns (besides Markdown headings). */ +export const SYMBOL_LANGS: readonly string[] = Object.keys(RULES); + export function hasSymbols(lang: string): boolean { return lang === "md" || lang in RULES; } @@ -151,8 +154,11 @@ export function symbols(lang: string, lines: readonly string[]): Sym[] { if (!t || t.startsWith("//") || t.startsWith("*") || (hash && t.startsWith("#")) || ((lang === "lua" || lang === "hs" || lang === "sql") && t.startsWith("--"))) continue; const depth = indentOf(raw); if (depth > 0 && TOP_LEVEL_ONLY.has(lang)) continue; + // Runs of spaces collapse to one first: several patterns let `\s+` and a class holding `\s` + // share the same spaces, which backtracks cubically (`public` + 990 spaces: 360 ms a line). + const c = t.replace(/[ \t]{2,}/g, " "); for (const [re, kind] of rules) { - const m = re.exec(t); + const m = re.exec(c); if (m && m[1]) { out.push({ name: m[1].trim(), kind, line: i, depth }); break; @@ -177,7 +183,8 @@ function mdHeadings(lines: readonly string[]): Sym[] { fence = f[1]!; continue; } - const m = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/.exec(l); + // Long lines are skipped like everywhere else in this file: the pattern is quadratic in them. + const m = l.length > MAX_LINE ? null : /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/.exec(l); if (m) { const name = (m[2] ?? "").trim(); if (name) out.push({ name, kind: "heading", line: i, depth: m[1]!.length - 1 }); @@ -195,8 +202,39 @@ function mdHeadings(lines: readonly string[]): Sym[] { /** Heading text as it renders (for the slug): emphasis, code ticks and link targets dropped. */ export function headingText(src: string): string { - return src - .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") - .replace(/[*_`~]/g, "") - .trim(); + return stripLinks(src).replace(/[*_`~]/g, "").trim(); +} + +/** + * `!?[text](target)` → `text`, exactly like `src.replace(/!?\[([^\][]*)\]\([^)]*\)/g, "$1")` but in + * one pass: the regex re-scanned to the end of the string for every link with no closing `)` + * (`![a](` repeated: quadratic). + */ +export function stripLinks(s: string): string { + let out = ""; + let i = 0; // everything before i is in `out` + let at = 0; // where to look for the next "[" + const n = s.length; + for (;;) { + const open = s.indexOf("[", at); + if (open < 0) break; + // The text runs to the first "]" and may not contain "[": at a "[" the search restarts there. + let close = open + 1; + while (close < n && s[close] !== "]" && s[close] !== "[") close++; + if (close >= n) break; // no "]" left: nothing later can match either + if (s[close] === "[") { + at = close; + continue; + } + if (s[close + 1] !== "(") { + at = close + 1; + continue; + } + const end = s.indexOf(")", close + 2); + if (end < 0) break; // no ")" left: no later link can close either + const start = open > i && s[open - 1] === "!" ? open - 1 : open; + out += s.slice(i, start) + s.slice(open + 1, close); + i = at = end + 1; + } + return out + s.slice(i); } diff --git a/src/client/term/terminal.ts b/src/client/term/terminal.ts index 1de0ebd..95d72cf 100644 --- a/src/client/term/terminal.ts +++ b/src/client/term/terminal.ts @@ -204,10 +204,16 @@ const enum S { export const MAX_SCROLLBACK = 10_000; +/** Most UTF-16 units one cell's cluster holds (the longest emoji ZWJ sequences are about 35). */ +const MAX_CLUSTER = 64; +/** Longest OSC 8 target kept as a link. */ +const MAX_LINK_URI = 2048; + /** * Headless VT/xterm emulator: parser + screen model. Rendering lives in renderer.ts. * Coordinates are 0-based internally. */ + export class Terminal { cols: number; rows: number; @@ -224,6 +230,8 @@ export class Terminal { modes!: Modes; title = ""; links: string[] = [""]; + /** uri → id: a lookup per OSC 8 (a scan of `links` made 65 000 links take 81 s). */ + private linkIds = new Map(); private linkId = 0; /** Rows changed since the renderer last painted. */ dirty: Uint8Array; @@ -585,7 +593,9 @@ export class Terminal { const line = this.lines[cur.y]!; let x = cur.wrapPending ? cur.x : cur.x - 1; if (x >= 0 && line.text[x] === "" && x > 0) x--; - if (x >= 0) { + // A cluster is a few code points: past MAX_CLUSTER, marks are dropped (a megabyte of U+0301, + // or ZWJ + REP, grew one cell without bound until the tab died). + if (x >= 0 && line.text[x]!.length < MAX_CLUSTER) { line.text[x] += ch; this.lastChar += ch; // so a following emoji can join a ZWJ sequence this.markDirty(cur.y); @@ -598,7 +608,7 @@ export class Terminal { const line = this.lines[cur.y]!; let x = cur.wrapPending ? cur.x : cur.x - 1; if (x > 0 && line.text[x] === "") x--; - if (x >= 0) { + if (x >= 0 && line.text[x]!.length < MAX_CLUSTER) { line.text[x] += ch; this.lastChar = ch; this.markDirty(cur.y); @@ -661,7 +671,7 @@ export class Terminal { const cur = this.cur; switch (c) { case 0x07: - this.host.bell?.(); + if (!this.silent) this.host.bell?.(); // never for replayed history break; case 0x08: // BS (xterm: pending wrap is dropped and the cursor still moves left) cur.wrapPending = false; @@ -997,6 +1007,7 @@ export class Terminal { this.savedPrimaryCursor = null; this.decoder = new TextDecoder("utf-8"); this.links = [""]; + this.linkIds.clear(); this.linkId = 0; this.scrolledOff = 0; this.lastChar = ""; @@ -1141,11 +1152,19 @@ export class Terminal { return; } case "b": { - // REP: repeat the preceding graphic character. - const ch = this.lastChar; - if (!ch) return; - const cp = ch.codePointAt(0)!; - for (let i = Math.min(this.p(0, 1), 65535); i > 0; i--) this.print(cp, ch); + // REP: repeat the preceding graphic character: its base code point only, never a whole + // cluster (one ending in ZWJ would join every repeat into one cell). More repeats than + // the screen holds only overwrite it again: 8 KB of REP used to scroll for 12 s. + if (!this.lastChar) return; + const cp = this.lastChar.codePointAt(0)!; + const ch = String.fromCodePoint(cp); + this.lastChar = ch; + // Past a screenful the output repeats with the period of one row (half a row of wide + // characters): the remainder keeps the cursor where the full count would leave it. + const n = Math.min(this.p(0, 1), 65535); + const cap = this.cols * this.rows; + const period = charWidth(cp) === 2 ? Math.max(1, Math.floor(this.cols / 2)) : this.cols; + for (let i = n > cap ? cap + ((n - cap) % period) : n; i > 0; i--) this.print(cp, ch); return; } case "c": @@ -1234,7 +1253,9 @@ export class Terminal { return; } if (inter === "$" && f === "p") { - // DECRQM: report mode state. 1 = set, 2 = reset, 0 = unknown. + // DECRQM: report mode state. 1 = set, 2 = reset, 0 = unknown. Only for the two real forms: + // the reply echoes the prefix, and `>>>…` came back verbatim into the shell's input. + if (pre !== "" && pre !== "?") return; const m = this.p(0, 0); const v = pre === "?" ? this.privateModeState(m) : m === 4 ? (this.modes.insert ? 1 : 2) : 0; this.respond(`\x1b[${pre}${m};${v}$y`); @@ -1423,16 +1444,18 @@ export class Terminal { case "8": { // OSC 8 ; params ; URI const uri = arg.slice(arg.indexOf(";") + 1); - if (!uri) { + // Over-long targets, and new ones once the table is full, print as plain text: ids are + // never reused, since re-pointing one would turn an earlier, trusted link into this one. + let id = uri && uri.length <= MAX_LINK_URI ? this.linkIds.get(uri) ?? 0 : 0; + if (!id && uri && uri.length <= MAX_LINK_URI && this.linkId < 0xfffe) { + id = ++this.linkId; + this.links[id] = uri; + this.linkIds.set(uri, id); + } + if (!id) { this.cur.fl &= 0xffff; return; } - let id = this.links.lastIndexOf(uri); - if (id <= 0) { - this.linkId = (this.linkId % 0xfffe) + 1; - id = this.linkId; - this.links[id] = uri; - } this.cur.fl = (this.cur.fl & 0xffff) | (id << 16); return; } @@ -1449,8 +1472,10 @@ export class Terminal { case "4": { const parts = arg.split(";"); const c = this.host.colors?.(); - for (let i = 0; i + 1 < parts.length; i += 2) { - if (parts[i + 1] === "?" && c) { + // At most 256 answers per sequence (one per palette entry): a megabyte of `;1;?` used to + // send 200 000 replies into the shell's input. + for (let i = 0, n = 0; i + 1 < parts.length && n < 256; i += 2) { + if (parts[i + 1] === "?" && c && ++n) { const n = Number(parts[i]); const hex = c.palette[n]; if (hex) this.respond(`\x1b]4;${n};${xrgb(hex)}\x1b\\`); diff --git a/src/client/term/view.ts b/src/client/term/view.ts index 01fa280..a4db7c7 100644 --- a/src/client/term/view.ts +++ b/src/client/term/view.ts @@ -99,6 +99,7 @@ export class TerminalView { private leftAltDown = false; private composing = false; private disposed = false; + private bellQueued = false; constructor(private opts: TerminalViewOptions) { this.el = document.createElement("div"); @@ -113,10 +114,16 @@ export class TerminalView { .then(() => window.dispatchEvent(new CustomEvent("cv-toast", { detail: `A terminal program copied ${t.length.toLocaleString()} characters to your clipboard` }))) .catch(() => {}), colors: () => ({ fg: opts.theme.fg, bg: opts.theme.bg, cursor: opts.theme.cursor, palette: this.renderer.palette }), + // One flash per batch of output: each BEL forced a layout, and 200 KB of them froze the page. bell: () => { - this.el.classList.remove("bell"); - void this.el.offsetWidth; - this.el.classList.add("bell"); + if (this.bellQueued) return; + this.bellQueued = true; + queueMicrotask(() => { + this.bellQueued = false; + this.el.classList.remove("bell"); + void this.el.offsetWidth; + this.el.classList.add("bell"); + }); }, onLine: (text) => { this.pendingLines.push(text); @@ -571,7 +578,9 @@ export class TerminalView { this.opts.send(`\x1b[<${button | mods};${x + 1};${y + 1}${press ? "M" : "m"}`); } else { const b = (press || button >= 64 ? button : 3) | mods; - if (x > 222 || y > 222) return; + // Legacy reports are single bytes 32..127 here: anything above goes out UTF-8 encoded, and a + // stray byte of that reaches the program as a keystroke. Past column/row 94 use SGR (1006). + if (32 + b > 127 || 33 + x > 127 || 33 + y > 127) return; this.opts.send(`\x1b[M${String.fromCharCode(32 + b, 33 + x, 33 + y)}`); } } diff --git a/src/client/terminals.ts b/src/client/terminals.ts index 21fa861..3da0922 100644 --- a/src/client/terminals.ts +++ b/src/client/terminals.ts @@ -1,7 +1,7 @@ import { TerminalView } from "./term/view.ts"; import type { Theme } from "./term/renderer.ts"; import { icons } from "./icons.ts"; -import { esc, h, local, MOD } from "./util.ts"; +import { esc, h, local, MOD, showHidden } from "./util.ts"; import { wsUrl } from "./auth.ts"; interface Session { @@ -31,6 +31,7 @@ function newId() { /** Terminal panel: tabs of PTY sessions that survive page reloads. */ export class TerminalPanel { + private tabsQueued = false; readonly el: HTMLElement; private tabsEl: HTMLElement; private body: HTMLElement; @@ -119,7 +120,13 @@ export class TerminalPanel { }, onTitle: (t) => { s.title = t || this.shellName; - this.renderTabs(); + // One tab-bar render per batch of output (30 000 title changes took over a second). + if (this.tabsQueued) return; + this.tabsQueued = true; + queueMicrotask(() => { + this.tabsQueued = false; + this.renderTabs(); + }); }, onOpenPath: this.onOpenPath, onOpenUrl: this.onOpenUrl, @@ -265,8 +272,8 @@ export class TerminalPanel { private renderTabs() { for (const [i, s] of this.sessions.entries()) { s.tabEl.className = `term-tab${s === this.active ? " active" : ""}${s.exited ? " exited" : ""}`; - s.tabEl.innerHTML = `${esc(s.title || `Terminal ${i + 1}`)}`; - s.tabEl.title = s.title; + s.tabEl.innerHTML = `${esc(showHidden(s.title || `Terminal ${i + 1}`))}`; + s.tabEl.title = showHidden(s.title); s.tabEl.querySelector(".tab-close")!.addEventListener("click", (e) => { e.stopPropagation(); this.close(s); diff --git a/src/client/tree.ts b/src/client/tree.ts index e0628ce..f2fedcf 100644 --- a/src/client/tree.ts +++ b/src/client/tree.ts @@ -1,6 +1,6 @@ import { api, type Entry } from "./api.ts"; import { chevron, fileIcon, folderIcon } from "./icons.ts"; -import { dirname, esc, h, IS_MAC } from "./util.ts"; +import { dirname, esc, h, IS_MAC, showHidden } from "./util.ts"; import { showContextMenu } from "./menu.ts"; import type { GitState } from "./git.ts"; @@ -187,7 +187,7 @@ export class FileTree { (isDir ? ` aria-expanded="${n.expanded}"` : "") + `>` + (n.note ? `` : isDir ? `${chevron}${folderIcon(n.expanded)}` : `${fileIcon(n.name)}`) + - `${esc(n.name)}` + + `${esc(showHidden(n.name))}` + (n.link ? `↗` : "") + (changed ? `` : "") + gitBadge + diff --git a/src/client/util.ts b/src/client/util.ts index 43ec112..6f03d26 100644 --- a/src/client/util.ts +++ b/src/client/util.ts @@ -25,9 +25,9 @@ export function h( return el; } -const ESC: Record = { "&": "&", "<": "<", ">": ">", '"': """ }; +const ESC: Record = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; export function esc(s: string): string { - return s.replace(/[&<>"]/g, (c) => ESC[c]!); + return s.replace(/[&<>"']/g, (c) => ESC[c]!); } export function basename(p: string): string { @@ -53,11 +53,23 @@ export function formatBytes(n: number): string { let settings: Record = {}; let saveTimer = 0; +/** Keys changed since the last save. Only these are sent (and merged by the server). */ +let dirty = new Set(); + function saveSettings(keepalive = false) { clearTimeout(saveTimer); - void fetch("/api/settings", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(settings), keepalive }).catch(() => {}); + if (!dirty.size) return; + // Just the changed keys: whole snapshots from two windows overwrote each other, and a large + // snapshot exceeded the 64 KB a keepalive request (the save on close) may carry. + const changes: Record = {}; + for (const k of dirty) changes[k] = k in settings ? settings[k] : null; + dirty = new Set(); + void fetch("/api/settings", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(changes), keepalive }).catch(() => {}); } +/** Runs before the save on close: pending debounced writes (layout) flush into the store. */ +const beforeSave: (() => void)[] = []; + /** * Settings persisted by the server (~/.config/codeviewer), so they survive relaunches of the * app window (which uses a throwaway browser profile and a random port). @@ -69,13 +81,21 @@ export const store = { } catch { settings = {}; } - window.addEventListener("pagehide", () => saveSettings(true)); + window.addEventListener("pagehide", () => { + for (const f of beforeSave) f(); + saveSettings(true); + }); + }, + /** Registers a flush run just before the save on close (a debounced store.set would be lost). */ + beforeSave(f: () => void) { + beforeSave.push(f); }, get(key: string, def: T): T { return key in settings ? (settings[key] as T) : def; }, set(key: string, v: unknown) { settings[key] = v; + dirty.add(key); clearTimeout(saveTimer); saveTimer = window.setTimeout(() => saveSettings(), 300); }, @@ -183,6 +203,14 @@ export function showHidden(s: string): string { }); } +/** HIDDEN minus the tab: file contents (code, diffs, previews) indent with tabs, and `tab-size` lays them out. */ +const HIDDEN_IN_TEXT = /[\x00-\x08\x0a-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g; + +/** showHidden for file contents: every HIDDEN character but the tab becomes an escape. Names keep the strict rule. */ +export function showHiddenText(s: string): string { + return s.replace(HIDDEN_IN_TEXT, (c) => showHidden(c)); +} + /** True for targets that own Cmd+A / Cmd+C themselves (inputs, textareas such as the terminal's, editable hosts). */ export function isEditable(t: EventTarget | null): boolean { const e = t as HTMLElement | null; diff --git a/src/server/browser.ts b/src/server/browser.ts index a266a23..43454cf 100644 --- a/src/server/browser.ts +++ b/src/server/browser.ts @@ -1,4 +1,4 @@ -import { existsSync, realpathSync } from "node:fs"; +import { existsSync, realpathSync, rmSync } from "node:fs"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import os from "node:os"; @@ -70,6 +70,9 @@ export function appWindowArgs(url: string, profile: string): string[] { return [ `--app=${url}`, `--user-data-dir=${profile}`, + // Nothing on disk: a normal profile recorded the #t= launch URL (History, Favicons), so any + // process in a terminal could read the session token and type into the other terminals. + "--incognito", "--window-size=1440,920", "--no-first-run", "--no-default-browser-check", @@ -84,6 +87,8 @@ export function appWindowArgs(url: string, profile: string): string[] { "--disable-features=Translate,MediaRouter,OptimizationHints,AutofillServerCommunication,DialMediaRouteProvider,InterestFeedContentSuggestions", "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1", "--proxy-server=127.0.0.1:9", + // WebRTC ignores the proxy: without this a page could still send STUN/TURN packets to any host. + "--webrtc-ip-handling-policy=disable_non_proxied_udp", "--class=codeviewer", ]; } @@ -93,8 +98,37 @@ export function appWindowArgs(url: string, profile: string): string[] { * local user (`ps`, /proc//cmdline). So browsers are pointed at a bootstrap page in a * private 0700 directory (file 0600) that redirects to the real URL; it is deleted shortly after. */ +/** Temp dirs holding the token (boot pages, window profiles) that exist right now. */ +const tokenDirs = new Set(); + +/** Boot pages only (the page loads them once, at launch). */ +const bootDirs = new Set(); + +/** Deletes the boot pages now: called when the page has connected, so they live for seconds, not a minute. */ +export function removeBootPages() { + for (const d of bootDirs) { + try { + rmSync(d, { recursive: true, force: true }); + } catch {} + tokenDirs.delete(d); + } + bootDirs.clear(); +} + +/** Deletes every boot page and window profile now, synchronously (for an exit that skips `finally`). */ +export function removeWindowFiles() { + for (const d of tokenDirs) { + try { + rmSync(d, { recursive: true, force: true }); + } catch {} + } + tokenDirs.clear(); +} + export async function bootPage(url: string): Promise<{ fileUrl: string; cleanup(): Promise }> { const dir = await mkdtemp(path.join(os.tmpdir(), "codeviewer-boot-")); // mkdtemp creates 0700 + tokenDirs.add(dir); + bootDirs.add(dir); const file = path.join(dir, "open.html"); const safe = url.replace(/[^\w:/.#=?&%[\]-]/g, ""); await writeFile(file, `Codeviewer`, { mode: 0o600 }); @@ -102,6 +136,8 @@ export async function bootPage(url: string): Promise<{ fileUrl: string; cleanup( const cleanup = async () => { if (done) return; done = true; + tokenDirs.delete(dir); + bootDirs.delete(dir); await rm(dir, { recursive: true, force: true }).catch(() => {}); }; setTimeout(() => void cleanup(), 60_000).unref(); @@ -121,6 +157,7 @@ export function closeAppWindow() { export async function runAppWindow(browser: string, url: string): Promise<"closed" | "failed"> { const profile = await mkdtemp(path.join(os.tmpdir(), "codeviewer-profile-")); + tokenDirs.add(profile); const boot = await bootPage(url); const started = Date.now(); let stderr = ""; @@ -147,6 +184,7 @@ export async function runAppWindow(browser: string, url: string): Promise<"close } finally { appWindow = null; await boot.cleanup(); + tokenDirs.delete(profile); await rm(profile, { recursive: true, force: true }).catch(() => {}); } } @@ -198,3 +236,17 @@ export async function pickFolder(): Promise { if (which("kdialog")) return run(["kdialog", "--getexistingdirectory", os.homedir()]); return os.homedir(); } + +/** http:// on a loopback address or one of this machine's own interfaces (a `--host` server). */ +export function isLocalViewerUrl(raw: string, interfaces = os.networkInterfaces()): boolean { + let u: URL; + try { + u = new URL(raw); + } catch { + return false; + } + if (u.protocol !== "http:" || u.username || u.password || !u.port) return false; + const host = u.hostname.replace(/^\[|\]$/g, ""); + if (host === "localhost" || host === "127.0.0.1" || host === "::1") return true; + return Object.values(interfaces).some((list) => (list ?? []).some((a) => a.address === host)); +} diff --git a/src/server/files.ts b/src/server/files.ts index facf177..32276c9 100644 --- a/src/server/files.ts +++ b/src/server/files.ts @@ -1,4 +1,4 @@ -import { open, readdir, realpath, stat } from "node:fs/promises"; +import { open, readdir, readlink, realpath, stat } from "node:fs/promises"; import { constants } from "node:fs"; import path from "node:path"; @@ -45,16 +45,22 @@ export function extOf(p: string): string { export const IMAGE_EXTS: readonly string[] = Object.keys(IMAGE_MIME); export function imageMime(p: string): string | undefined { - return IMAGE_MIME[extOf(p)]; + // Own keys only: `x.constructor` must not find Object.prototype.constructor. + const ext = extOf(p); + return Object.hasOwn(IMAGE_MIME, ext) ? IMAGE_MIME[ext] : undefined; } +/** Path separators in a root-relative path from the client. */ +export const SEP_RE: RegExp = path.sep === "\\" ? /[\\/]+/ : /\/+/; + /** * Resolves a root-relative path ("" = root, "/"-separated) to an absolute real path. * Returns null if the path is malformed, missing, or escapes the root (including via symlinks). */ export async function resolveSafe(rootReal: string, rel: string): Promise { if (rel.includes("\0") || path.isAbsolute(rel) || /^[a-zA-Z]:/.test(rel)) return null; - const parts = rel.split(/[\\/]+/).filter(Boolean); + // "\\" is a separator only on Windows: on POSIX `a\\b` is one file name, never `a/b`. + const parts = rel.split(SEP_RE).filter(Boolean); if (parts.some((p) => p === "..")) return null; const joined = path.join(rootReal, ...parts); if (!within(rootReal, joined)) return null; @@ -149,21 +155,41 @@ export function looksBinary(buf: Uint8Array): boolean { * Opens a resolved path for reading, safely: never follows a symlink swapped in after * resolveSafe() (O_NOFOLLOW), never blocks on FIFOs/devices (O_NONBLOCK), and refuses * anything that isn't a regular file. + * + * With `root`, the file actually opened must still be inside it: O_NOFOLLOW guards only the last + * component, so a parent directory swapped for a symlink between resolveSafe() and open() would + * otherwise open a file outside the root. Linux names the open file (/proc/self/fd); elsewhere the + * path is resolved again and must lead to the same inode. */ -export async function openRegular(abs: string) { +export async function openRegular(abs: string, root?: string) { + // Refuse FIFOs and device nodes before opening them (an open alone can have side effects); + // the fstat below still decides, for whatever was actually opened. + if (!(await stat(abs)).isFile()) throw new Error("not a regular file"); const fh = await open(abs, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - const st = await fh.stat(); - if (!st.isFile()) { + try { + const st = await fh.stat(); + if (!st.isFile()) throw new Error("not a regular file"); + if (root !== undefined && !(await stillInside(root, abs, fh.fd, st))) throw new Error("file changed while opening"); + return { fh, st }; + } catch (e) { await fh.close(); - throw new Error("not a regular file"); + throw e; } - return { fh, st }; } -export async function readForView(abs: string, forceText = false): Promise<{ meta: FileMeta; body?: Uint8Array }> { +async function stillInside(root: string, abs: string, fd: number, st: { dev: number; ino: number }): Promise { + const viaProc = await readlink(`/proc/self/fd/${fd}`).catch(() => null); + if (viaProc !== null && path.isAbsolute(viaProc)) return within(root, viaProc); + const real = await realpath(abs).catch(() => null); + if (real === null || !within(root, real)) return false; + const again = await stat(real).catch(() => null); + return again !== null && again.dev === st.dev && again.ino === st.ino; +} + +export async function readForView(abs: string, forceText = false, root?: string): Promise<{ meta: FileMeta; body?: Uint8Array }> { const s0 = await stat(abs); if (s0.isDirectory()) return { meta: { kind: "dir", size: s0.size, mtime: s0.mtimeMs, truncated: false } }; - const { fh, st: s } = await openRegular(abs); + const { fh, st: s } = await openRegular(abs, root); const meta: FileMeta = { kind: "text", size: s.size, mtime: s.mtimeMs, truncated: false }; if (imageMime(abs) && !forceText) { await fh.close(); diff --git a/src/server/git.ts b/src/server/git.ts index 5cc2a38..dce3ad0 100644 --- a/src/server/git.ts +++ b/src/server/git.ts @@ -1,6 +1,6 @@ import { watch, type FSWatcher } from "node:fs"; import { HEAVY_DIRS } from "./index.ts"; -import { existsSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; import path from "node:path"; import { looksBinary, openRegular } from "./files.ts"; @@ -18,6 +18,10 @@ import { looksBinary, openRegular } from "./files.ts"; * - fsmonitor off, signature verification off, no textconv, submodules ignored (their * configs are untrusted too), no external diff, no pager, no prompts. * - GIT_OPTIONAL_LOCKS=0: never writes the index or takes locks. + * - No transport at all (GIT_ALLOW_PROTOCOL empty, GIT_NO_LAZY_FETCH): a partial clone fetches + * missing blobs from its promisor remote on demand, and the remote's config names programs + * (remote.*.uploadpack, core.sshCommand). The environment variable wins over any + * protocol.*.allow a hostile config sets. * Every argument is passed as an array (no shell). */ @@ -107,12 +111,20 @@ export function isOid(s: string): boolean { return OID.test(s); } +/** + * Variables that would point git at another repository than the one under the viewed folder (set + * when codeviewer is started from a git hook, or by the user's shell): discovery is by the folder only. + */ +const LOCATORS = ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_NAMESPACE", "GIT_CONFIG", "GIT_GRAFT_FILE", "GIT_SHALLOW_FILE", "GIT_REPLACE_REF_BASE"]; + const ENV = { - ...process.env, + ...Object.fromEntries(Object.entries(process.env).filter(([k]) => !LOCATORS.includes(k))), GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0", GIT_PAGER: "cat", LC_ALL: "C", + GIT_ALLOW_PROTOCOL: "", + GIT_NO_LAZY_FETCH: "1", }; const HARDEN = [ @@ -132,6 +144,20 @@ const HARDEN = [ "-c", "diff.orderFile=/dev/null", // Counts must not silently turn into "binary" because a config set the threshold to 1. "-c", "core.bigFileThreshold=512m", + // A submodule is a repository with its own (untrusted) config, which the overrides below never + // read: git must not look inside one (diff ran `git status` in it, and its filters). + "-c", "diff.ignoreSubmodules=all", + // Unlimited rename detection (renameLimit=0) took 52 s on 15 000 renames, past the run timeout, + // so Changes and status came back empty. + "-c", "diff.renameLimit=1000", + "-c", "status.renameLimit=1000", + // Commit text in UTF-8, whatever the repository asks for (UTF-16 broke every parser). + "-c", "i18n.logOutputEncoding=UTF-8", + // `git diff` rewrites the index to refresh stat info (it ignores GIT_OPTIONAL_LOCKS), and + // writing the index runs the repository's own post-index-change hook: an extracted tarball with + // touched files ran it from the Changes view. No refresh (diffFiles copes), and no hooks at all. + "-c", "diff.autoRefreshIndex=false", + "-c", "core.hooksPath=/dev/null", ]; /** Wall clock for one git run; a hostile config can make git read /dev/zero forever. */ @@ -167,12 +193,15 @@ export class Git { static async open(root: string): Promise { const bin = Bun.which("git"); if (!bin) return null; + // Even rev-parse reads .git/config, HEAD and commondir to their end: check them first. + if (!preflight(root)) return null; const r = await run(bin, root, [...HARDEN, "rev-parse", "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-object-format"]); if (r.code !== 0) return null; const [top, gitDir, common, format] = new TextDecoder().decode(r.out).trim().split("\n"); if (!top || !gitDir) return null; const rel = path.relative(top, root); - if (rel.startsWith("..")) return null; + // Outside the work tree ("..", "../x"); a folder merely named "..x" is inside it. + if (rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel)) return null; const prefix = rel ? rel.split(path.sep).join("/") + "/" : ""; const commonDir = common ? path.resolve(root, common) : gitDir; const emptyTree = EMPTY_TREE[format ?? "sha1"] ?? EMPTY_TREE.sha1!; @@ -180,19 +209,35 @@ export class Git { return new Git(bin, root, prefix, [...new Set([gitDir, commonDir])], env, emptyTree); } - private overrides: Promise> | null = null; + private overrides: Promise | null> | null = null; - /** The run environment, with every filter/diff/merge driver any config defines blanked (cached until config changes). */ - private neutralize(): Promise> { + /** + * The run environment, with every filter/diff/merge driver any config defines blanked; null when + * the drivers could not be listed, and then no git call runs. Shared by the calls of one + * operation and recomputed for the next (fresh()): a config can include files the watcher does + * not see (a tracked file via include.path, ~/.gitconfig), and a driver added there must not run. + */ + private neutralize(): Promise | null> { return (this.overrides ??= this.computeOverrides()); } - private async computeOverrides(): Promise> { - const r = await run(this.bin, this.root, [...HARDEN, "config", "--name-only", "--get-regexp", "^(filter|diff|merge)\\."], 1 << 20, this.env); + /** Starts an operation: the overrides are listed again before its first git call. */ + private fresh() { + this.overrides = null; + } + + private async computeOverrides(): Promise | null> { + // Re-checked for every operation: the repository can change while it is open. + if (!this.gitDirs.every(gitDirFilesOk)) return null; + const r = await run(this.bin, this.root, [...HARDEN, "config", "--name-only", "-z", "--get-regexp", "^(filter|diff|merge)\\."], 16 << 20, this.env); + // Fail closed: a cut-off or failed listing would leave every driver after the cut armed. + // (Exit code 1 means no driver is defined.) + if (r.truncated || (r.code !== 0 && r.code !== 1)) return null; const pairs: [string, string][] = []; const drivers = new Set(); - for (const key of new TextDecoder().decode(r.out).split("\n")) { - const m = /^(filter|diff|merge)\.(.+)\.[^.]+$/.exec(key.trim()); + // NUL separated (-z): a key is never split, whatever its subsection holds. + for (const key of new TextDecoder().decode(r.out).split("\0")) { + const m = /^(filter|diff|merge)\.(.+)\.[^.]+$/s.exec(key); if (m) drivers.add(`${m[1]}.${m[2]}`); } for (const d of drivers) { @@ -211,8 +256,10 @@ export class Git { return env; } - private async git(args: string[], maxBytes = MAX_OUT) { - return run(this.bin, this.root, [...HARDEN, ...args], maxBytes, await this.neutralize()); + private async git(args: string[], maxBytes = MAX_OUT, signal?: AbortSignal) { + const env = await this.neutralize(); + if (!env) return { code: 1, out: new Uint8Array(0), truncated: false }; + return run(this.bin, this.root, [...HARDEN, ...args], maxBytes, env, signal); } /** @@ -220,6 +267,7 @@ export class Git { * from `git ls-files -z --cached --others --exclude-standard --deduplicate -- .`; null on failure. */ async listFiles(): Promise { + this.fresh(); const r = await this.git(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--deduplicate", "--", "."]); if (r.code !== 0) return null; return new TextDecoder().decode(r.out).split("\0").filter((f) => f && !f.split("/").some((seg) => HEAVY_DIRS.has(seg))); @@ -238,6 +286,7 @@ export class Git { } private async compute(): Promise { + this.fresh(); const [st, stash, head] = await Promise.all([ this.git(["status", "--porcelain=v2", "--branch", "-z", "--untracked-files=normal", "--ignored=traditional", "--ignore-submodules=all", "--", "."]), this.git(["rev-list", "--walk-reflogs", "--count", "refs/stash"]), @@ -314,6 +363,7 @@ export class Git { * `rev` (a full object id) reads the file as of that commit instead. */ async base(rel: string, maxBytes: number, rev = "HEAD"): Promise { + this.fresh(); if (rev !== "HEAD" && !isOid(rev)) return null; const r = await this.git(["cat-file", "blob", `${rev}:./${rel}`], maxBytes); return r.code === 0 && !r.truncated ? r.out : null; @@ -371,6 +421,7 @@ export class Git { } private async computeChanges(scope: "head" | "branch"): Promise { + this.fresh(); const headOid = await this.rev("HEAD^{commit}"); const base = headOid ? await this.detectBase(headOid) : null; const branch = scope === "branch" && !!base && !base.same; @@ -409,10 +460,12 @@ export class Git { */ private async diffFiles(rev: string): Promise<{ files: ChangeEntry[]; truncated: boolean }> { const r = await this.git([ - "diff", "--raw", "--numstat", "-z", "-M", "--no-color", "--no-ext-diff", "--no-textconv", "--no-relative", "-O/dev/null", rev, "--", ".", + "diff", "--raw", "--numstat", "-z", "-M", "--no-abbrev", "--no-color", "--no-ext-diff", "--no-textconv", "--no-relative", "--ignore-submodules=all", "-O/dev/null", rev, "--", ".", ]); - if (r.code !== 0) return { files: [], truncated: false }; + // A run cut by the timeout or the size cap is an incomplete list, not a clean tree. + if (r.code !== 0) return { files: [], truncated: r.truncated }; const recs = new TextDecoder().decode(r.out).split("\0"); + const statOnly = new Map(); const byPath = new Map(); let truncated = false; const strip = (p: string) => (p.startsWith(this.prefix) ? p.slice(this.prefix.length) : null); @@ -435,6 +488,8 @@ export class Git { } const status = (/^[MADRCTUX]$/.test(st) ? st : "M") as ChangeEntry["status"]; const e: ChangeEntry = { path: rel, status, added: null, removed: null, binary: false }; + // Kept for the stat-only check below (not part of the entry sent to the client). + if (status === "M" && parts[0] === parts[1]) statOnly.set(e, parts[2] ?? ""); if (two) { const o = strip(oldP); // A rename from outside the viewed folder: show it as new, never a path above the root. @@ -460,6 +515,23 @@ export class Git { e.removed = Number(m[2]); } } + // Without an index refresh, files whose stat info changed but whose content did not are listed + // as modified: a text file then has no numstat line, a binary one "-\t-". Drop the first, and + // hash the second (hash-object reads, never writes) to compare with the blob it came from. + const binaries: ChangeEntry[] = []; + for (const [e, oid] of statOnly) { + if (e.added === null && !e.binary) byPath.delete(e.path); + else if (e.binary && isOid(oid)) binaries.push(e); + } + for (let i = 0; i < binaries.length && i < 400; i += 100) { + const chunk = binaries.slice(i, i + 100); + const h = await this.git(["hash-object", "--no-filters", "--", ...chunk.map((e) => e.path)], 1 << 16); + if (h.code !== 0) continue; + const oids = new TextDecoder().decode(h.out).trim().split("\n"); + chunk.forEach((e, n) => { + if (oids[n] === statOnly.get(e)) byPath.delete(e.path); + }); + } return { files: [...byPath.values()], truncated }; } @@ -478,7 +550,8 @@ export class Git { /** Untracked, not ignored files under the root, with line counts for the first few text files. */ private async untracked(): Promise<{ files: ChangeEntry[]; truncated: boolean }> { const r = await this.git(["ls-files", "-z", "--others", "--exclude-standard", "--", "."]); - if (r.code !== 0) return { files: [], truncated: false }; + // A run cut by the timeout or the size cap is an incomplete list, not a clean tree. + if (r.code !== 0) return { files: [], truncated: r.truncated }; // A nested repository is listed as "dir/": not a file, nothing to count or diff. const all = new TextDecoder() .decode(r.out) @@ -496,7 +569,7 @@ export class Git { private async countLines(e: ChangeEntry) { try { - const { fh, st } = await openRegular(path.join(this.root, ...e.path.split("/"))); + const { fh, st } = await openRegular(path.join(this.root, ...e.path.split("/")), this.root); try { if (st.size > COUNT_MAX_BYTES) return; const buf = new Uint8Array(64 * 1024); @@ -523,8 +596,12 @@ export class Git { } catch {} } - async blame(rel: string, line: number): Promise { - const r = await this.git(["blame", "--porcelain", "--no-textconv", "-L", `${line},${line}`, "--", rel], 1 << 20); + async blame(rel: string, line: number, signal?: AbortSignal): Promise { + this.fresh(); + // --no-ignore-revs-file: blame.ignoreRevsFile from a hostile config (a missing file fails every + // blame, /dev/zero reads forever). `-c blame.ignoreRevsFile=` does not reset it: the empty value + // sorts first among the configured ones. A repository's real ignore list is not applied either. + const r = await this.git(["blame", "--porcelain", "--no-textconv", "--no-ignore-revs-file", "-L", `${line},${line}`, "--", rel], 1 << 20, signal); if (r.code !== 0) return null; const out = new TextDecoder().decode(r.out).split("\n"); const hash = out[0]?.split(" ")[0] ?? ""; @@ -549,7 +626,9 @@ export class Git { * the file was renamed into the root from outside it ends at that rename (shown as added). */ async history(rel: string): Promise { - const args = ["--literal-pathspecs", "log", "--follow", "-z", "-M", "--no-color", "--no-decorate", "--no-show-signature", "--no-ext-diff", "--no-textconv", "--no-relative", + this.fresh(); + // --root: the commit that added the file is listed even under log.showRoot=false. + const args = ["--literal-pathspecs", "log", "--follow", "--root", "-z", "-M", "--no-color", "--no-decorate", "--no-show-signature", "--no-ext-diff", "--no-textconv", "--no-relative", "--name-status", "--format=%H%x00%P%x00%an%x00%at%x00%s", `-n${MAX_HISTORY}`]; // --diff-merges needs git 2.31; older ones list merges without files (they are skipped then). let r = await this.git([...args, "--diff-merges=first-parent", "--", rel], 4 << 20); @@ -585,13 +664,16 @@ export class Git { /** Duration of the last `git status`, to back off on huge or stat-dirty repositories. */ private lastMs = 0; - /** Schedules a debounced status refresh (at least 2× the last run's duration apart). */ + /** Schedules a status refresh, throttled (at least 2× the last run's duration apart). */ poke() { - if (this.timer) clearTimeout(this.timer); + // Already scheduled: keep that time. Restarting the wait on every poke meant a file written + // more often than every 200 ms (a log) held the status back for as long as it was written. + if (this.timer) return; this.timer = setTimeout(async () => { this.timer = null; const t0 = performance.now(); - const s = await this.status(); + const s = await this.status().catch(() => null); + if (!s) return; this.lastMs = performance.now() - t0; if (this.again) { this.again = false; @@ -651,6 +733,63 @@ export function parseHistory(out: string, prefix = ""): HistoryEntry[] { return res; } +/** + * Files git reads whole, and the most each may hold. A symlink to /dev/zero or a FIFO in their + * place made git allocate until memory ran out (or block for the 30 s run timeout), and no config + * key prevents those reads: each must be a regular file of a sane size, or git is not used at all. + */ +const GIT_FILES: [string, number][] = [ + ["HEAD", 1 << 20], ["commondir", 1 << 16], ["gitdir", 1 << 16], ["shallow", 64 << 20], ["config", 16 << 20], + ["config.worktree", 16 << 20], ["info/attributes", 16 << 20], ["info/exclude", 16 << 20], ["info/grafts", 16 << 20], + ["info/sparse-checkout", 16 << 20], ["objects/info/alternates", 1 << 20], ["objects/info/commit-graphs/commit-graph-chain", 1 << 20], + ["packed-refs", 2 ** 30], ["index", 4 * 2 ** 30], ["sharedindex", 4 * 2 ** 30], // not `4 << 30`: that overflows to 0 +]; + +/** True when every file of GIT_FILES that exists in `dir` is a regular file within its size. */ +export function gitDirFilesOk(dir: string): boolean { + for (const [name, max] of GIT_FILES) { + let st; + try { + st = lstatSync(path.join(dir, ...name.split("/"))); + } catch { + continue; // absent: fine + } + if (!st.isFile() || st.size > max) return false; + } + return true; +} + +/** + * Before git runs at all: the `.git` it will find for `root` (walking up; a `gitdir:` file followed), + * its common dir, and their files must all be safe to read. False refuses git for this folder. + */ +function preflight(root: string): boolean { + for (let d = root; ; d = path.dirname(d)) { + const dotgit = path.join(d, ".git"); + let st; + try { + st = lstatSync(dotgit); + } catch { + if (path.dirname(d) === d) return true; // no repository: nothing for git to read + continue; + } + let gitDir = dotgit; + if (!st.isDirectory()) { + // A worktree's `.git` file: small and regular (a FIFO or /dev/zero in its place is refused). + if (!st.isFile() || st.size > 4096) return false; + const m = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(dotgit, "utf8")); + if (!m) return true; // not a repository git recognises; it will say so itself + gitDir = path.resolve(d, m[1]!); + } + if (!gitDirFilesOk(gitDir)) return false; + let common = ""; + try { + common = readFileSync(path.join(gitDir, "commondir"), "utf8").trim(); // checked above: small, regular + } catch {} + return !common || gitDirFilesOk(path.resolve(gitDir, common)); + } +} + /** n-th space-separated field to the end of the record (paths may contain spaces). */ function field(rec: string, n: number): string { let idx = 0; @@ -658,8 +797,16 @@ function field(rec: string, n: number): string { return rec.slice(idx); } -async function run(bin: string, cwd: string, args: string[], maxBytes = MAX_OUT, env: Record = ENV) { - const p = Bun.spawn([bin, ...args], { cwd, env, stdin: "ignore", stdout: "pipe", stderr: "ignore" }); +async function run(bin: string, cwd: string, args: string[], maxBytes = MAX_OUT, env: Record = ENV, signal?: AbortSignal) { + if (signal?.aborted) return { code: 1, out: new Uint8Array(0), truncated: false }; + let p: ReturnType; + try { + p = Bun.spawn([bin, ...args], { cwd, env, stdin: "ignore", stdout: "pipe", stderr: "ignore" }); + } catch { + // The viewed folder was deleted (spawn fails on a missing cwd), or git was removed: a failed + // run, never a throw (from a status refresh timer it was an unhandled rejection: exit 1). + return { code: 1, out: new Uint8Array(0), truncated: false }; + } const chunks: Uint8Array[] = []; let size = 0; let truncated = false; @@ -667,6 +814,9 @@ async function run(bin: string, cwd: string, args: string[], maxBytes = MAX_OUT, truncated = true; p.kill(); }, RUN_TIMEOUT_MS); + // The request that wanted this answer is gone (the cursor moved on): stop the process now. + const onAbort = () => p.kill(); + signal?.addEventListener("abort", onAbort, { once: true }); const reader = (p.stdout as ReadableStream).getReader(); for (let r = await reader.read(); !r.done; r = await reader.read()) { if (size + r.value.length > maxBytes) { @@ -679,6 +829,7 @@ async function run(bin: string, cwd: string, args: string[], maxBytes = MAX_OUT, } const code = await p.exited; clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); const out = new Uint8Array(size); let o = 0; for (const c of chunks) { diff --git a/src/server/http.ts b/src/server/http.ts index 7f482c6..865c459 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -3,7 +3,7 @@ import { randomBytes, timingSafeEqual } from "node:crypto"; import path from "node:path"; import os from "node:os"; import { realpath, stat } from "node:fs/promises"; -import { listDir, readForView, resolveSafe, imageMime, toRel, looksBinary, openRegular, MAX_TEXT_BYTES } from "./files.ts"; +import { listDir, readForView, resolveSafe, imageMime, toRel, looksBinary, openRegular, MAX_TEXT_BYTES, SEP_RE } from "./files.ts"; import { FileIndex } from "./index.ts"; import { watchTree } from "./watch.ts"; import { PtyManager, type PtySocketData } from "./pty.ts"; @@ -33,6 +33,8 @@ export interface ServeOptions { searchScanner?: import("./search.ts").Scanner; /** Injected for tests: overrides SEARCH_DEADLINE_MS. */ searchDeadlineMs?: number; + /** Called once, when the first page connects its events socket (the launch page has loaded). */ + onFirstClient?: () => void; } type EventsSocketData = { kind: "events" }; @@ -63,6 +65,7 @@ export function startServer(opts: ServeOptions): { server: Server; u return updater.check().catch(() => null); })(); const events = new Set>(); + let firstClient = false; let origin = ""; let port = 0; const loopback = ["127.0.0.1", "localhost", "::1"].includes(opts.host); @@ -140,11 +143,14 @@ export function startServer(opts: ServeOptions): { server: Server; u h.set("x-content-type-options", "nosniff"); h.set("referrer-policy", "no-referrer"); h.set("x-frame-options", "DENY"); + // Other sites cannot load anything from here (an of the favicon told them we run, and on which port). + h.set("cross-origin-resource-policy", "same-origin"); return h; }; + // no-store: in --browser mode the user's real profile cached /api/info (the root path), listings and status. const json = (data: unknown, status = 200) => - new Response(JSON.stringify(data), { status, headers: securityHeaders(new Headers({ "content-type": "application/json" })) }); + new Response(JSON.stringify(data), { status, headers: securityHeaders(new Headers({ "content-type": "application/json", "cache-control": "no-store" })) }); const fail = (status: number, message: string) => json({ error: message }, status); const serveAsset = (req: Request, a: Asset) => { @@ -251,17 +257,27 @@ export function startServer(opts: ServeOptions): { server: Server; u const relParam = (url: URL) => url.searchParams.get("path") ?? ""; /** A well-formed root-relative path (for blobs read from history, which need not exist on disk). */ const relOk = (rel: string) => - !!rel && !rel.includes("\0") && !path.isAbsolute(rel) && !/^[a-zA-Z]:/.test(rel) && !rel.split(/[\\/]+/).some((p) => p === ".."); + !!rel && !rel.includes("\0") && !path.isAbsolute(rel) && !/^[a-zA-Z]:/.test(rel) && !rel.split(SEP_RE).some((p) => p === ".."); const server = Bun.serve({ port: opts.port, hostname: opts.host, idleTimeout: 0, + // Never Bun's development error page: it shows source, paths and the cwd to anyone who can + // make a handler throw (a `Host: [` header did, before any auth). + development: false, + error() { + return fail(500, "internal error"); + }, async fetch(req, srv) { - const url = new URL(req.url); - const p = url.pathname; - if (!hostOk(req)) return fail(421, "unexpected host"); + let url: URL; + try { + url = new URL(req.url); + } catch { + return fail(400, "bad request"); + } + const p = url.pathname; // The app shell (HTML/JS/CSS/fonts) is public and contains no secrets. const asset = assets[p === "/" ? "/index.html" : p]; @@ -290,8 +306,9 @@ export function startServer(opts: ServeOptions): { server: Server; u const id = url.searchParams.get("id") ?? ""; if (!/^[\w-]{6,64}$/.test(id)) return fail(400, "bad id"); // Same bounds as a resize message: an out-of-range size makes the PTY throw after the shell spawned. - const cols = Math.min(1000, Math.max(2, Number(url.searchParams.get("cols")) || 80)); - const rows = Math.min(500, Math.max(1, Number(url.searchParams.get("rows")) || 24)); + // Whole cells only, like a resize message (`| 0`): a fractional size reaches the PTY as is. + const cols = Math.min(1000, Math.max(2, Math.floor(Number(url.searchParams.get("cols"))) || 80)); + const rows = Math.min(500, Math.max(1, Math.floor(Number(url.searchParams.get("rows"))) || 24)); return srv.upgrade(req, { data: { kind: "pty", id, cols, rows } }) ? undefined : fail(400, "upgrade failed"); } return fail(404, "not found"); @@ -329,7 +346,7 @@ export function startServer(opts: ServeOptions): { server: Server; u const abs = await resolveSafe(opts.root, relParam(url)); if (!abs) return fail(404, "not found"); try { - const { meta, body } = await readForView(abs, url.searchParams.has("text")); + const { meta, body } = await readForView(abs, url.searchParams.has("text"), opts.root); const h = securityHeaders( new Headers({ "content-type": "text/plain; charset=utf-8", @@ -351,14 +368,15 @@ export function startServer(opts: ServeOptions): { server: Server; u const rel = relParam(url); const abs = await resolveSafe(opts.root, rel); if (!abs) return fail(404, "not found"); - const type = imageMime(rel) ?? MIME[rel.split(".").pop()?.toLowerCase() ?? ""] ?? "application/octet-stream"; + const ext = rel.split(".").pop()?.toLowerCase() ?? ""; + const type = imageMime(rel) ?? (Object.hasOwn(MIME, ext) ? MIME[ext] : undefined) ?? "application/octet-stream"; const h = securityHeaders(new Headers({ "content-type": type, "cache-control": "no-store" })); // Even if an SVG is opened directly, it must not run script. h.set("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"); // Read through a verified regular-file handle (no FIFOs/devices, no symlink swaps). let opened; try { - opened = await openRegular(abs); + opened = await openRegular(abs, opts.root); } catch (e) { return fail(400, (e as Error).message); } @@ -368,7 +386,7 @@ export function startServer(opts: ServeOptions): { server: Server; u await opened.fh.close(); return fail(413, "too large"); } - const limit = Number(url.searchParams.get("limit")); + const limit = Math.floor(Number(url.searchParams.get("limit"))); const total = limit > 0 ? Math.min(limit, opened.st.size) : opened.st.size; const fh = opened.fh; // One `bytes=a-b` / `bytes=a-` / `bytes=-n` range, so media elements can seek. @@ -457,7 +475,7 @@ export function startServer(opts: ServeOptions): { server: Server; u const rel = relParam(url); const line = Number(url.searchParams.get("line")); if (!g || !Number.isInteger(line) || line < 1 || !(await resolveSafe(opts.root, rel))) return fail(404, "no blame"); - const b = await g.blame(rel, line); + const b = await g.blame(rel, line, req.signal); // killed when the page moves on return b ? json(b) : fail(404, "no blame"); } @@ -505,11 +523,13 @@ export function startServer(opts: ServeOptions): { server: Server; u case "/api/settings": { if (req.method === "GET") return json(await settings.read()); - if (req.method !== "PUT") return fail(405, "GET or PUT"); + if (req.method !== "PUT" && req.method !== "PATCH") return fail(405, "GET, PUT or PATCH"); const text = await req.text(); if (text.length > 256 * 1024) return fail(413, "settings too large"); try { - await settings.write(JSON.parse(text)); + // PATCH: only the keys a window changed (null removes one), merged with what is on disk. + const body = JSON.parse(text); + await (req.method === "PATCH" ? settings.patch(body) : settings.write(body)); } catch { return fail(400, "invalid settings"); } @@ -539,7 +559,10 @@ export function startServer(opts: ServeOptions): { server: Server; u const given = body.path; const abs = path.isAbsolute(given) ? path.join(await realpath(path.dirname(given)).catch(() => path.dirname(given)), path.basename(given)) : null; const rel = abs !== null ? toRel(opts.root, abs) : given; - if (rel === null || !(await resolveSafe(opts.root, rel))) return fail(404, "path is outside the viewed directory"); + const target = rel === null ? null : await resolveSafe(opts.root, rel); + if (rel === null || !target) return fail(404, "path is outside the viewed directory"); + // Files only: the root itself, folders and FIFOs have nothing to open. + if (!(await stat(target).catch(() => null))?.isFile()) return fail(404, "not a file"); broadcast({ t: "open", path: rel, line: Number.isInteger(body.line) && body.line! > 0 ? body.line : 0 }); return json({ ok: true, clients: events.size }); } @@ -551,7 +574,13 @@ export function startServer(opts: ServeOptions): { server: Server; u maxPayloadLength: 16 * 1024 * 1024, perMessageDeflate: false, open(ws) { - if (ws.data.kind === "events") events.add(ws); + if (ws.data.kind === "events") { + events.add(ws); + if (!firstClient) { + firstClient = true; + opts.onFirstClient?.(); + } + } else ptys.open(ws as ServerWebSocket); }, message(ws, msg) { diff --git a/src/server/index.ts b/src/server/index.ts index 85a8de6..b83cfb0 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -23,8 +23,12 @@ export class FileIndex { private dirs: Set | null = null; + /** Bumped by invalidate(): a build that started before it must not store its (stale) result. */ + private gen = 0; + /** Forces a full rebuild on the next request. */ invalidate() { + this.gen++; this.files = null; this.building = null; this.text = null; @@ -47,9 +51,15 @@ export class FileIndex { } async get(): Promise { - if (!this.files) { - this.building ??= this.build().then((set) => (this.files = set)); - await this.building; + // A build that an invalidate() overtook does not store its result (it may have walked past a + // new file): walk again, a few times at most, so a tree that never settles still gets a list. + for (let tries = 0; !this.files; tries++) { + const gen = this.gen; + const set = await (this.building ??= this.build()); + if (this.gen === gen || tries >= 3) { + this.files ??= set; + this.building = null; + } } return (this.text ??= [...this.files!].join("\n")); } @@ -60,12 +70,19 @@ export class FileIndex { */ async apply(paths: string[]): Promise { const files = this.files; - if (!files) return true; + if (!files) { + // A build in progress may already have walked past these paths (heavy dirs it never walks + // do not count: a running build would restart on every event under target/ or .venv/). + if (this.building && paths.some((p) => !p.split("/").some((seg) => HEAVY_DIRS.has(seg)))) this.invalidate(); + return true; + } if (paths.length > 2000) { this.invalidate(); return true; } let changed = false; + // Directories as they were before this batch: only a path that was one can have files below it. + const dirs = this.knownDirs(); await Promise.all( paths.map(async (p) => { if (p.split("/").some((seg) => HEAVY_DIRS.has(seg))) return; @@ -81,20 +98,26 @@ export class FileIndex { changed = true; } } else if (st && !files.has(p)) { + // Like build(): a new symlink to a file outside the root is never listed. + if (!(await this.inside(abs))) return; files.add(p); this.dirs = null; changed = true; } else if (!st) { if (files.delete(p)) changed = true; - // A deleted directory: drop everything under it. - for (const f of files) if (f.startsWith(p + "/")) { + // A deleted directory: drop everything under it. Only for a known directory: scanning + // every file for every deleted path froze the server for 20 s on a 1 500-file `rm`. + if (dirs.has(p)) for (const f of files) if (f.startsWith(p + "/")) { files.delete(f); changed = true; } } }), ); - if (changed) this.text = null; + if (changed) { + this.text = null; + this.dirs = null; // a deleted directory recreated later must be walked again + } return changed; } diff --git a/src/server/main.ts b/src/server/main.ts index b9de59e..c5e09a5 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -4,7 +4,7 @@ import { existsSync } from "node:fs"; import path from "node:path"; import { randomBytes } from "node:crypto"; import { startServer, VERSION } from "./http.ts"; -import { closeAppWindow, findAppBrowser, findMacApp, openDefault, openPrivately, pickFolder, runAppWindow } from "./browser.ts"; +import { closeAppWindow, findAppBrowser, findMacApp, isLocalViewerUrl, openDefault, openPrivately, pickFolder, removeBootPages, removeWindowFiles, runAppWindow } from "./browser.ts"; const HELP = `codeviewer ${VERSION}: a fast, read-only code viewer with a built-in terminal @@ -32,10 +32,14 @@ async function openCommand(target: string | undefined) { const url = process.env.CODEVIEWER_URL; const token = process.env.CODEVIEWER_TOKEN; if (!url || !token) die("not inside a codeviewer terminal (CODEVIEWER_URL is not set)"); - const m = /^(.*?)(?::(\d+)(?:-\d+)?)?(?::\d+)?$/.exec(target)!; + // The variables can be overridden (direnv, a stale tmux environment): the token and the path go + // only to a codeviewer on this machine, and never follow a redirect elsewhere. + if (!isLocalViewerUrl(url)) die(`CODEVIEWER_URL is not a codeviewer on this machine: ${url}`); + const m = /^(.*?)(?::(\d+)(?:-\d+)?)?(?::\d+)?$/s.exec(target)!; const file = path.resolve(m[1]!); const res = await fetch(`${url}/api/open`, { method: "POST", + redirect: "error", headers: { "content-type": "application/json", "x-cv-token": token }, body: JSON.stringify({ path: file, line: Number(m[2] ?? 0) }), }).catch((e: Error) => die(`cannot reach viewer: ${e.message}`)); @@ -49,7 +53,8 @@ async function main() { let dir: string | null = null; let port = 0; let host = "127.0.0.1"; - let shell = process.env.SHELL || (existsSync("/bin/zsh") ? "/bin/zsh" : "/bin/bash"); + const defaultShell = process.env.SHELL || (existsSync("/bin/zsh") ? "/bin/zsh" : "/bin/bash"); + let shell = defaultShell; let open = true; let appWindow = true; let machine = false; @@ -103,15 +108,21 @@ async function main() { const relaunch = { available: () => windowed, run() { - Bun.spawn([process.execPath, root], { stdio: ["ignore", "ignore", "ignore"], detached: true } as Parameters[1]).unref(); + // The same interface and shell as this run (not --port: this process still holds it). + const keep = [...(host !== "127.0.0.1" ? ["--host", host] : []), ...(shell !== defaultShell ? ["--shell", shell] : [])]; + Bun.spawn([process.execPath, root, ...keep], { stdio: ["ignore", "ignore", "ignore"], detached: true } as Parameters[1]).unref(); closeAppWindow(); quit(); }, }; - const app = startServer({ root, port, host, shell, token, openExternal: (u) => void openDefault(u), relaunch }); + const app = startServer({ root, port, host, shell, token, openExternal: (u) => void openDefault(u), relaunch, onFirstClient: removeBootPages }); const quit = () => { app.stop(); + // process.exit skips runAppWindow's finally: close the window and delete its profile and boot + // page here (both hold the session token: Chromium records the launch URL). + closeAppWindow(); + removeWindowFiles(); process.exit(0); }; process.on("SIGINT", quit); @@ -130,7 +141,10 @@ async function main() { const dim = (s: string) => `\x1b[2m${s}\x1b[0m`; console.log(`\n \x1b[1;38;2;125;170;255m◆ codeviewer\x1b[0m ${dim(VERSION)}\n`); console.log(` ${dim("root")} ${root}`); - console.log(` ${dim("url ")} ${app.url}\n`); + // The URL carries the session token. Printed for a person at a terminal (or --no-open, where it + // is the only way in); a desktop launch's stdout goes to the system journal, so not there. + const showToken = process.stdout.isTTY || !open; + console.log(` ${dim("url ")} ${showToken ? app.url : app.url.replace(/#.*$/, "")}\n`); if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") { console.log(` \x1b[33m⚠ Listening on ${host}: anyone with the URL gets a shell on this machine.\x1b[0m\n`); } diff --git a/src/server/pty.ts b/src/server/pty.ts index e797807..dc92765 100644 --- a/src/server/pty.ts +++ b/src/server/pty.ts @@ -14,10 +14,226 @@ const EXIT_GRACE_MS = 60_000; * Claude Code 2.1.316 as no change at all; 120 ms apart they repaint the whole screen (measured). */ const REPAINT_NUDGE_MS = 150; +/** Live shells per server (each holds a PTY, a process group and a 512 KB replay ring). */ +const MAX_SESSIONS = 64; /** Private modes that change how input/output must be interpreted; restored on replay. */ const TRACKED = [1, 25, 1000, 1002, 1003, 1004, 1006, 2004, 47, 1047, 1049]; -const MODE_RE = /\x1b\[\?([\d;]+)([hl])/g; +/** Parser states, as in the client's parser (src/client/term/terminal.ts, `feed`). */ +const enum P { + Ground, + Esc, + EscInter, + Csi, + CsiIgnore, + Osc, + OscEsc, + Str, + StrEsc, +} + +/** A ModeTracker's state at one point of the stream (stored with each replay chunk). */ +export interface TrackerState { + modes: [number, boolean][]; + st: P; + prefix: string; + inter: string; + params: number[]; + cur: number; + sub: boolean; +} + +/** + * Follows DECSET/DECRST of the TRACKED modes exactly as the client's parser does: UTF-8 decoded + * first, C1 controls only in the ground state, nothing inside OSC/DCS/SOS/PM/APC strings, ESC + * restarting a sequence, CAN/SUB aborting one, a prefix after parameters ignoring the sequence, + * RIS and DECSTR resetting. A regex over the bytes disagreed with the client (a C1 CSI right after + * ESC, or inside a string, switched bracketed paste off in the replay prefix only), so after a + * reload a pasted command ran line by line. + */ +export class ModeTracker { + readonly modes = new Map(); + private st = P.Ground; + private prefix = ""; + private inter = ""; + private params: number[] = []; + private cur = -1; + private sub = false; + private dec = new TextDecoder("utf-8"); + + static from(s: TrackerState): ModeTracker { + const t = new ModeTracker(); + for (const [n, on] of s.modes) t.modes.set(n, on); + t.st = s.st; + t.prefix = s.prefix; + t.inter = s.inter; + t.params = [...s.params]; + t.cur = s.cur; + t.sub = s.sub; + return t; + } + + state(): TrackerState { + return { modes: [...this.modes], st: this.st, prefix: this.prefix, inter: this.inter, params: [...this.params], cur: this.cur, sub: this.sub }; + } + + /** Feeds output bytes (UTF-8, split anywhere); true when a tracked mode changed. */ + write(data: Uint8Array): boolean { + return this.text(this.dec.decode(data, { stream: true })); + } + + /** Feeds decoded text; true when a tracked mode changed. */ + text(s: string): boolean { + let changed = false; + for (let i = 0; i < s.length; i++) if (this.feed(s.charCodeAt(i), s[i]!)) changed = true; + return changed; + } + + private feed(c: number, ch: string): boolean { + if (c === 0x18 || c === 0x1a) { + this.st = P.Ground; + return false; + } + if (c < 0x20 && this.st !== P.Osc && this.st !== P.Str) { + if (c === 0x1b && this.st !== P.OscEsc && this.st !== P.StrEsc) { + this.st = P.Esc; + this.inter = ""; + } + return false; // other C0 controls are executed and leave the state alone + } + switch (this.st) { + case P.Ground: + if (c === 0x9b) this.csiStart(); + else if (c === 0x9d) this.st = P.Osc; + else if (c === 0x90 || c === 0x98 || c === 0x9e || c === 0x9f) this.st = P.Str; + return false; + case P.Esc: + return this.escape(c, ch); + case P.EscInter: + if (c >= 0x20 && c <= 0x2f) { + this.inter += ch; + return false; + } + this.st = P.Ground; + return this.escDispatch(ch); + case P.Csi: + return this.csiChar(c, ch); + case P.CsiIgnore: + if (c >= 0x40 && c <= 0x7e) this.st = P.Ground; + return false; + case P.Osc: + if (c === 0x07 || c === 0x9c) this.st = P.Ground; + else if (c === 0x1b) this.st = P.OscEsc; + return false; + case P.Str: + if (c === 0x1b) this.st = P.StrEsc; + else if (c === 0x07 || c === 0x9c) this.st = P.Ground; + return false; + case P.OscEsc: + case P.StrEsc: + if (c === 0x5c) { + this.st = P.Ground; + return false; + } + this.st = P.Esc; + return this.escape(c, ch); + } + return false; + } + + private escape(c: number, ch: string): boolean { + if (c === 0x5b) this.csiStart(); + else if (c === 0x5d) this.st = P.Osc; + else if (c === 0x50 || c === 0x58 || c === 0x5e || c === 0x5f) this.st = P.Str; + else if (c >= 0x20 && c <= 0x2f) { + this.inter = ch; + this.st = P.EscInter; + } else { + this.st = P.Ground; + return this.escDispatch(ch); + } + return false; + } + + /** ESC c (RIS) resets every mode. */ + private escDispatch(ch: string): boolean { + const inter = this.inter; + this.inter = ""; + if (inter || ch !== "c" || !this.modes.size) return false; + this.modes.clear(); + return true; + } + + private csiStart() { + this.st = P.Csi; + this.prefix = ""; + this.inter = ""; + this.params = []; + this.cur = -1; + this.sub = false; + } + + private csiChar(c: number, ch: string): boolean { + if (c >= 0x30 && c <= 0x39) { + if (!this.sub) this.cur = Math.min(0xffff, (this.cur < 0 ? 0 : this.cur) * 10 + (c - 0x30)); + } else if (c === 0x3b) { + this.params.push(this.cur); + this.cur = -1; + this.sub = false; + } else if (c === 0x3a) this.sub = true; // sub-parameters: the mode is the first number + else if (c >= 0x3c && c <= 0x3f) { + if (this.params.length || this.cur >= 0) this.st = P.CsiIgnore; + else this.prefix += ch; + } else if (c >= 0x20 && c <= 0x2f) this.inter += ch; + else if (c >= 0x40 && c <= 0x7e) { + this.params.push(this.cur); + this.st = P.Ground; + return this.dispatch(ch); + } else this.st = P.CsiIgnore; + return false; + } + + private dispatch(f: string): boolean { + let changed = false; + if (this.inter === "!" && f === "p" && !this.prefix) { + // DECSTR: of the tracked modes, cursor keys and cursor visibility go back to their defaults. + for (const n of [1, 25]) if (this.modes.delete(n)) changed = true; + return changed; + } + if (this.inter || this.prefix !== "?" || (f !== "h" && f !== "l")) return false; + const on = f === "h"; + for (const n of this.params) { + if (n === 9 || n === 1000 || n === 1002 || n === 1003) { + // One mouse mode at a time, like the client: setting one replaces the others, resetting + // any of them turns reporting off (9, X10, reports like 1000). + const m = n === 9 ? 1000 : n; + for (const k of [1000, 1002, 1003]) if ((k !== m || !on) && this.modes.delete(k)) changed = true; + if (on && !this.modes.get(m)) { + this.modes.set(m, true); + changed = true; + } + continue; + } + if (!TRACKED.includes(n)) continue; + if (n === 47 || n === 1047 || n === 1049) { + for (const k of [47, 1047, 1049]) if (k !== n && this.modes.delete(k)) changed = true; + } + if (this.modes.get(n) !== on) changed = true; + this.modes.set(n, on); + } + return changed; + } +} + +/** The prefix that restores `modes` on a fresh terminal (a hidden cursor is the one "off" that differs from the default). */ +export function modesPrefix(modes: Map): string { + let str = ""; + for (const [n, on] of modes) { + if (on) str += `\x1b[?${n}h`; + else if (n === 25) str += "\x1b[?25l"; + } + return str; +} export interface PtyOptions { shell: string; @@ -41,15 +257,17 @@ class PtySession { private proc: ReturnType; private term: { write(d: string | Uint8Array): void; resize(c: number, r: number): void; close(): void }; /** Replay ring: each chunk remembers the mode-restoring prefix in effect before it. */ - private chunks: { data: Uint8Array; modes: string }[] = []; + /** Replay ring: each chunk remembers the mode tracker's state before it. */ + private chunks: { data: Uint8Array; state: TrackerState }[] = []; private bytes = 0; /** The ring has dropped output, so a replay no longer holds everything since the session began. */ private ringTrimmed = false; private size: { cols: number; rows: number }; private nudge: ReturnType | null = null; - private modes = new Map(); - private modesStr = ""; - private carry = ""; + private tracker = new ModeTracker(); + private get modes() { + return this.tracker.modes; + } private pending: Uint8Array[] = []; private pendingBytes = 0; private flushTimer: ReturnType | null = null; @@ -102,7 +320,7 @@ class PtySession { private output(data: Uint8Array) { const copy = data.slice(); - this.chunks.push({ data: copy, modes: this.modesStr }); + this.chunks.push({ data: copy, state: this.tracker.state() }); this.bytes += copy.length; while (this.bytes > REPLAY_BYTES && this.chunks.length > 1) { this.bytes -= this.chunks.shift()!.data.length; @@ -116,30 +334,12 @@ class PtySession { else this.flushTimer ??= setTimeout(() => this.flush(), FLUSH_MS); } - /** Remembers DECSET/DECRST of tracked modes (sequences may straddle chunks). */ + /** + * Remembers DECSET/DECRST of tracked modes. A sequence may straddle chunks: the tail from the last + * escape is carried into the next one (re-applying a complete one there is harmless: same order). + */ private trackModes(data: Uint8Array) { - const text = this.carry + new TextDecoder("latin1").decode(data); - this.carry = text.slice(-24); - let changed = false; - MODE_RE.lastIndex = 0; - for (let m; (m = MODE_RE.exec(text)); ) { - for (const n of m[1]!.split(";").map(Number)) { - if (!TRACKED.includes(n)) continue; - const on = m[2] === "h"; - if (n === 47 || n === 1047 || n === 1049) { - this.modes.delete(47); - this.modes.delete(1047); - this.modes.delete(1049); - } - if (this.modes.get(n) !== on) changed = true; - this.modes.set(n, on); - } - } - if (changed) { - let str = ""; - for (const [n, on] of this.modes) if (on) str += `\x1b[?${n}h`; - this.modesStr = str; - } + this.tracker.write(data); } private flush() { @@ -191,12 +391,18 @@ class PtySession { if (!this.chunks.length) return; const first = this.chunks[0]!; let head = first.data; + const t = ModeTracker.from(first.state); if (this.ringTrimmed) { // History was trimmed: the first chunk may start mid-escape or mid-UTF-8. const nl = head.indexOf(0x0a); - if (nl >= 0) head = head.subarray(nl + 1); + if (nl >= 0) { + // The skipped bytes may change modes (a TUI exiting): the prefix must include them. + t.write(head.subarray(0, nl + 1)); + head = head.subarray(nl + 1); + } } - const parts = [new TextEncoder().encode(first.modes), head, ...this.chunks.slice(1).map((c) => c.data)]; + const prefix = modesPrefix(t.modes); + const parts = [new TextEncoder().encode(prefix), head, ...this.chunks.slice(1).map((c) => c.data)]; ws.send(JSON.stringify({ t: "replay" })); ws.sendBinary(new Uint8Array(Bun.concatArrayBuffers(parts))); } @@ -278,7 +484,16 @@ export class PtyManager { const { id, cols, rows } = ws.data; let s = this.sessions.get(id); if (!s) { - s = new PtySession(id, this.opts, cols, rows, (gone) => this.sessions.delete(gone)); + // A shell that cannot start (deleted folder, bad --shell, out of fds or processes) is this + // terminal's failure, not the server's: a throw from a socket handler exits the process. + try { + if (this.sessions.size >= MAX_SESSIONS) throw new Error("too many terminals"); + s = new PtySession(id, this.opts, cols, rows, (gone) => this.sessions.delete(gone)); + } catch { + ws.send(JSON.stringify({ t: "exit", code: -1 })); + ws.close(1011, "could not start a shell"); + return; + } this.sessions.set(id, s); } s.attach(ws); @@ -294,6 +509,8 @@ export class PtyManager { } catch { return; } + // `null`, `1`, `"x"` parse fine: reading `.t` off null would throw, and a throw here exits the server. + if (!ctl || typeof ctl !== "object") return; if (ctl.t === "resize" && ctl.cols && ctl.rows) { ws.data.cols = Math.min(1000, Math.max(2, ctl.cols | 0)); ws.data.rows = Math.min(500, Math.max(1, ctl.rows | 0)); diff --git a/src/server/search.ts b/src/server/search.ts index e6fc82b..aa7fbc0 100644 --- a/src/server/search.ts +++ b/src/server/search.ts @@ -277,6 +277,8 @@ export class Candidates { if (!o.ignored) { this.cached ??= this.git.then((g) => g?.listFiles() ?? null).catch(() => null); list = await this.cached; + // Nothing listed (the viewed folder is itself git-ignored): search the file index instead. + if (list && !list.length) list = null; } list ??= (await this.index.get()).split("\n").filter(Boolean); return filterByGlobs(list, parseGlobs(o.include), parseGlobs(o.exclude)); diff --git a/src/server/search.worker.ts b/src/server/search.worker.ts index b03fcc1..e36ac85 100644 --- a/src/server/search.worker.ts +++ b/src/server/search.worker.ts @@ -1,6 +1,6 @@ // Search worker: scans candidate files with a verified-regular-file handle and posts results. // The user's pattern only ever runs here, so the main thread can terminate() it at any time. -import { closeSync, constants, fstatSync, openSync, readSync, realpathSync } from "node:fs"; +import { closeSync, constants, fstatSync, openSync, readlinkSync, readSync, realpathSync, statSync } from "node:fs"; import path from "node:path"; import { looksBinary, MAX_TEXT_BYTES, within } from "./files.ts"; import { compileQuery, matchText, MAX_TOTAL_MATCHES, type WorkerJob, type WorkerMsg } from "./search.ts"; @@ -17,6 +17,21 @@ function decode(buf: Uint8Array): string { const post = (m: WorkerMsg) => postMessage(m); +/** The file behind `fd` is inside the root: Linux names it (/proc/self/fd); elsewhere `real` must still resolve inside to the same inode. */ +function openedInside(root: string, real: string, fd: number, st: { dev: number; ino: number }): boolean { + try { + const named = readlinkSync(`/proc/self/fd/${fd}`); + if (path.isAbsolute(named)) return within(root, named); + } catch {} + try { + const again = realpathSync(real); + const s = statSync(again); + return within(root, again) && s.dev === st.dev && s.ino === st.ino; + } catch { + return false; + } +} + function run({ req, cancel }: WorkerJob): void { const flag = new Int32Array(cancel); const c = compileQuery(req.q, req.opts); @@ -26,13 +41,11 @@ function run({ req, cancel }: WorkerJob): void { } // Like the find bar (which tests line by line), ^ and $ anchor at line boundaries. const re = new RegExp(c.re.source, c.re.flags.includes("m") ? c.re.flags : c.re.flags + "m"); - let rootReal = req.root; - try { - rootReal = realpathSync(req.root); - } catch { - /* unreadable root: every open below fails and counts as skipped */ - } + // Resolved once at startup (main.ts). Resolving it again per search followed a root that was + // later replaced by a symlink (mv root root.old; ln -s ~ root), which /api/file never does. + const rootReal = req.root; const seen = new Set(); + let listed: Set | null = null; let files = 0; let matches = 0; let skipped = 0; @@ -55,10 +68,20 @@ function run({ req, cancel }: WorkerJob): void { continue; // gone since it was listed (deleted but still in the index): nothing was skipped } if (!within(rootReal, real)) throw new Error("skip"); - fd = openSync(path.join(rootReal, rel), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + // A symlink to a file that is itself a candidate: the match is reported under the real path + // (the one git and the editor know), whichever of the two is listed first. + const realRel = path.relative(rootReal, real).split(path.sep).join("/"); + if (realRel !== rel && (listed ??= new Set(req.files)).has(realRel)) continue; + // Only regular files are opened: opening a FIFO or a device node can have side effects. + const pre = statSync(real); + if (pre.isDirectory()) continue; // submodules and nested repositories are listed as paths, not files + if (!pre.isFile()) throw new Error("skip"); + fd = openSync(real, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); const st = fstatSync(fd); - if (st.isDirectory()) continue; // submodules and nested repositories are listed as paths, not files if (!st.isFile() || st.size > MAX_TEXT_BYTES) throw new Error("skip"); + // O_NOFOLLOW guards only the last component: a parent swapped for a symlink between the + // check and the open (259 outside hits in 40 searches) is caught here, on the open file. + if (!openedInside(rootReal, real, fd, st)) throw new Error("skip"); const id = `${st.dev}:${st.ino}`; if (seen.has(id)) continue; seen.add(id); diff --git a/src/server/settings.ts b/src/server/settings.ts index 83e3145..f6d57ce 100644 --- a/src/server/settings.ts +++ b/src/server/settings.ts @@ -1,5 +1,6 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import os from "node:os"; +import { randomBytes } from "node:crypto"; import path from "node:path"; /** Per-user config directory (XDG on Linux, Application Support on macOS). */ @@ -26,16 +27,84 @@ export class Settings { } } + /** Replaces every setting. */ write(data: unknown): Promise { - if (!data || typeof data !== "object" || Array.isArray(data)) return Promise.reject(new Error("object expected")); - // Serialize writes and replace atomically so a crash never leaves a torn file. - // A failed write must not poison every later one. - this.queue = this.queue.catch(() => {}).then(async () => { - await mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 }); - const tmp = `${this.file}.${process.pid}.tmp`; - await writeFile(tmp, JSON.stringify(data, null, 2), { mode: 0o600 }); - await rename(tmp, this.file); + let text: string; + try { + text = serialize(data); + } catch (e) { + return Promise.reject(e); + } + return this.enqueue(() => this.persist(text)); + } + + /** + * Sets the given keys and keeps every other one as it is on disk. Each window sends only what it + * changed: whole snapshots from two windows (or two folders) overwrote each other's keys. + * A key that is set moves to the end, so the per-folder `tabs:` entries are in recency order and + * only the newest MAX_TAB_ROOTS are kept. + */ + patch(changes: unknown): Promise { + if (!changes || typeof changes !== "object" || Array.isArray(changes)) return Promise.reject(new Error("object expected")); + if (depth(changes) > MAX_DEPTH) return Promise.reject(new Error("settings nested too deeply")); + return this.enqueue(async () => { + // Merged in a Map: the keys come from the request, and a plain object would take + // `__proto__`, `constructor` and friends as property writes on itself. + const cur = new Map(Object.entries(await this.read())); + for (const [k, v] of Object.entries(changes)) { + cur.delete(k); + if (v !== null) cur.set(k, v); // null removes the key + } + const roots = [...cur.keys()].filter((k) => k.startsWith("tabs:")); + for (const k of roots.slice(0, Math.max(0, roots.length - MAX_TAB_ROOTS))) cur.delete(k); + await this.persist(serialize(Object.fromEntries(cur))); }); + } + + /** Serialized and atomic (temp file + rename): a crash never leaves a torn file, and a failed write does not poison later ones. */ + private enqueue(job: () => Promise): Promise { + this.queue = this.queue.catch(() => {}).then(job); return this.queue; } + + private async persist(text: string) { + await mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 }); + // A fresh random name created exclusively ("wx"): a symlink planted at a predictable name + // would have made writeFile follow it and overwrite its target. + const tmp = `${this.file}.${randomBytes(8).toString("hex")}.tmp`; + await writeFile(tmp, text, { mode: 0o600, flag: "wx" }); + await rename(tmp, this.file); + } +} + +/** + * Indented JSON of a settings object. Indented output grows with the square of the nesting depth + * (40 KB of `[` became an 800 MB file): settings are shallow, so deep values are refused and the + * written size is capped. + */ +function serialize(data: unknown): string { + if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("object expected"); + if (depth(data) > MAX_DEPTH) throw new Error("settings nested too deeply"); + const text = JSON.stringify(data, null, 2); + if (text.length > MAX_BYTES) throw new Error("settings too large"); + return text; +} + +/** Folders whose open tabs are remembered (`tabs:` keys), most recently saved first. */ +const MAX_TAB_ROOTS = 100; +const MAX_DEPTH = 32; +const MAX_BYTES = 1024 * 1024; + +/** Nesting depth of a JSON value, stopping as soon as it passes MAX_DEPTH (no deep recursion). */ +function depth(v: unknown): number { + let level: unknown[] = [v]; + let d = 0; + while (level.length) { + d++; + if (d > MAX_DEPTH) return d; + const next: unknown[] = []; + for (const x of level) if (x && typeof x === "object") for (const y of Object.values(x as object)) next.push(y); + level = next; + } + return d - 1; } diff --git a/src/server/update.ts b/src/server/update.ts index b53930f..876114b 100644 --- a/src/server/update.ts +++ b/src/server/update.ts @@ -1,5 +1,5 @@ import { access, chmod, mkdir, mkdtemp, readdir, rename, rm } from "node:fs/promises"; -import { constants, existsSync, realpathSync } from "node:fs"; +import { constants, existsSync, readFileSync, realpathSync } from "node:fs"; import path from "node:path"; // The release-signing public key (PEM, Ed25519), compiled into the binary. import updateKeyPem from "../../packaging/update-key.pub" with { type: "text" }; @@ -34,27 +34,47 @@ export interface Install { asset(version: string): string; } -/** Works out how this copy of codeviewer was installed, from the executable's location. */ +/** True in a `bun build --compile` binary: its entry point lives in the embedded file system. */ +export const isCompiledBinary = (): boolean => Bun.main.startsWith("/$bunfs/") || /^[A-Z]:\\~BUN\\/.test(Bun.main); + +const readText = (p: string): string | null => { + try { + return readFileSync(p, "utf8"); + } catch { + return null; + } +}; + +/** + * Works out how this copy of codeviewer was installed, from the executable's location. What gets + * replaced must be unmistakably codeviewer's: an app bundle with our identifier, a package + * directory with all of its marker files. A folder that merely has a `chromium/chrome` next to a + * slim binary (~/.local/bin) was moved away whole, with every other tool in it. + */ export function detectInstall( execPath = process.execPath, platform: string = process.platform, arch: string = process.arch, exists: (p: string) => boolean = existsSync, + compiled: boolean = isCompiledBinary(), + read: (p: string) => string | null = readText, ): Install { let exe = execPath; try { exe = realpathSync(execPath); } catch {} const a = arch === "arm64" ? "arm64" : "x64"; - if (/^bun(-debug)?(\.exe)?$/.test(path.basename(exe))) { + // Running from source (any bun runtime, whatever its file name): never replace the runtime. + if (!compiled || /^bun([-.\d].*)?(\.exe)?$/.test(path.basename(exe))) { return { kind: "source", target: "", asset: () => "" }; } const app = /^(.*\.app)\/Contents\/Resources\/codeviewer$/.exec(exe); - if (platform === "darwin" && app) { + if (platform === "darwin" && app && read(path.join(app[1]!, "Contents", "Info.plist"))?.includes("de.21no.codeviewer")) { return { kind: "macos-app", target: app[1]!, asset: (v) => `Codeviewer-${v}-macos-${a}.dmg` }; } const dir = path.dirname(exe); - if (platform === "linux" && exists(path.join(dir, "chromium", "chrome"))) { + const pkg = ["chromium/chrome", "share/codeviewer.desktop", "install.sh"].every((f) => exists(path.join(dir, ...f.split("/")))); + if (platform === "linux" && pkg) { return { kind: "linux-app", target: dir, asset: (v) => `codeviewer-${v}-linux-${a}.tar.gz` }; } const os = platform === "darwin" ? "macos" : "linux"; @@ -140,6 +160,42 @@ const MAX_SUMS_BYTES = 1 << 20; /** No release asset is anywhere near this; the download stops here before any verification. */ const MAX_ASSET_BYTES = 1024 * 1024 * 1024; +/** The release description from the API is a few KB; anything near this is not GitHub. */ +const MAX_API_BYTES = 4 << 20; + +/** Reads a body, failing as soon as it passes `max` bytes (no content-length needed). */ +async function readCapped(res: Response, max: number, what: string): Promise { + const chunks: Uint8Array[] = []; + let size = 0; + if (res.body) { + const reader = res.body.getReader(); + for (let r = await reader.read(); !r.done; r = await reader.read()) { + size += r.value.length; + if (size > max) { + await reader.cancel().catch(() => {}); + throw new Error(`${what} is too large`); + } + chunks.push(r.value); + } + } + const out = new Uint8Array(size); + let o = 0; + for (const c of chunks) { + out.set(c, o); + o += c.length; + } + return out; +} + +/** The last path segment of an asset URL, short and printable (for error messages). */ +function assetName(url: string): string { + let name = url; + try { + name = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? ""); + } catch {} + return name.replace(/[^\w.+-]/g, "?").slice(0, 100); +} + /** Parses a SHA256SUMS file into name → hex digest. */ export function parseSums(text: string): Map { const out = new Map(); @@ -220,9 +276,13 @@ export class Updater { }; const res = await this.fetch(this.api, { headers: { accept: "application/vnd.github+json", "user-agent": `codeviewer/${this.version}` } }); if (!res.ok) throw new Error(`update check failed: GitHub answered ${res.status}`); - const rel = (await res.json()) as { tag_name?: string; html_url?: string; body?: string; draft?: boolean; prerelease?: boolean; assets?: { name: string; browser_download_url: string }[] }; + // Capped like every other download: res.json() would buffer whatever the server sends. + const rel = JSON.parse(new TextDecoder().decode(await readCapped(res, MAX_API_BYTES, "the release description"))) as { tag_name?: string; html_url?: string; body?: string; draft?: boolean; prerelease?: boolean; assets?: { name: string; browser_download_url: string }[] }; const version = (rel.tag_name ?? "").replace(/^v/, ""); - if (!/^\d+\.\d+\.\d+/.test(version) || rel.draft || rel.prerelease) { + // The whole tag is checked, not a prefix: the version becomes part of a file name in the + // staging directory, so "1.2.3/../../x" must never get that far. + // Pre-releases are never offered (SECURITY.md), even when the release isn't flagged as one. + if (!/^\d+\.\d+\.\d+(?:\+[0-9A-Za-z.-]+)?$/.test(version) || rel.draft || rel.prerelease) { this.cached = { info: base, release: null }; return base; } @@ -264,11 +324,10 @@ export class Updater { private async get(url: string): Promise { const res = await this.fetch(this.assetUrl(url), { headers: { "user-agent": `codeviewer/${this.version}` } }); - if (!res.ok) throw new Error(`download failed (${res.status}): ${url}`); + // The asset's own file name, not the URL the API answered with (shown in the update dialog). + if (!res.ok) throw new Error(`download failed (${res.status}): ${assetName(url)}`); if (Number(res.headers.get("content-length")) > MAX_SUMS_BYTES) throw new Error("checksum file is too large"); - const body = new Uint8Array(await res.arrayBuffer()); - if (body.length > MAX_SUMS_BYTES) throw new Error("checksum file is too large"); - return body; + return readCapped(res, MAX_SUMS_BYTES, "checksum file"); } /** @@ -280,8 +339,10 @@ export class Updater { this.busy = true; try { const info = await this.check(true); - const release = this.cached?.release; - if (!info.available || !release) throw new Error("already up to date"); + // The release that this check found, not whatever this.cached holds by now (a concurrent + // check may have replaced it), and it must still be newer than what is running. + const release = this.cached?.info === info ? this.cached.release : null; + if (!info.available || !release || compareVersions(release.version, this.version) <= 0) throw new Error("already up to date"); if (!info.canInstall) throw new Error(info.reason ?? "this update can't be installed automatically"); const inst = this.install; const name = inst.asset(release.version); @@ -297,6 +358,7 @@ export class Updater { // 2. Download into a staging dir next to the target (same file system → atomic rename). const stage = await mkdtemp(path.join(path.dirname(inst.target), ".codeviewer-update-")); + let keepStage = false; try { const file = path.join(stage, name); const actual = await this.download(release.assets[name]!, file, (pct) => progress("download", `${pct}%`)); @@ -317,8 +379,15 @@ export class Updater { progress("done", release.version); this.cached = null; return { version: release.version, kind: inst.kind, target: inst.target }; + } catch (e) { + // The old install could not be put back: it lives in the staging dir now, so keep it. + if (e instanceof RestoreFailed) { + keepStage = true; + throw new Error(`${e.message}; the previous version is in ${stage}`); + } + throw e; } finally { - await rm(stage, { recursive: true, force: true }).catch(() => {}); + if (!keepStage) await rm(stage, { recursive: true, force: true }).catch(() => {}); } } finally { this.busy = false; @@ -331,7 +400,6 @@ export class Updater { if (!res.ok || !res.body) throw new Error(`download failed (${res.status})`); const total = Number(res.headers.get("content-length")) || 0; if (total > MAX_ASSET_BYTES) throw new Error("release asset is too large"); - const hash = new Bun.CryptoHasher("sha256"); const sink = Bun.file(dest).writer(); let got = 0; let last = -1; @@ -342,8 +410,8 @@ export class Updater { await reader.cancel().catch(() => {}); throw new Error("release asset is too large"); } - hash.update(r.value); - sink.write(r.value); + // Awaited: a failed write (disk full) rejects here instead of going unnoticed. + await sink.write(r.value); const p = total ? Math.floor((got / total) * 100) : 0; if (p !== last) { last = p; @@ -351,6 +419,14 @@ export class Updater { } } await sink.end(); + // Hash what is on disk, not what arrived: a short write must fail the checksum. + const hash = new Bun.CryptoHasher("sha256"); + let onDisk = 0; + for await (const chunk of Bun.file(dest).stream()) { + onDisk += chunk.length; + hash.update(chunk); + } + if (onDisk !== got) throw new Error(`download was not written completely (${onDisk} of ${got} bytes)`); return hash.digest("hex"); } } @@ -398,7 +474,14 @@ async function swap(target: string, staged: string, stage: string) { try { await rename(staged, target); } catch (e) { - await rename(old, target).catch(() => {}); + try { + await rename(old, target); + } catch { + throw new RestoreFailed(`installing failed (${(e as Error).message}) and the previous version could not be restored`); + } throw e; } } + +/** swap() could not put the old install back: the staging dir holds the only copy. */ +class RestoreFailed extends Error {} diff --git a/test/browser.coverage.test.ts b/test/browser.coverage.test.ts index b8ed09b..a576b7d 100644 --- a/test/browser.coverage.test.ts +++ b/test/browser.coverage.test.ts @@ -160,3 +160,12 @@ test.skipIf(process.platform === "darwin")("pickFolder uses zenity, then kdialog await script("zenity", `exit 1`); // cancel expect(await pick()).toBeNull(); }); + +test("isLocalViewerUrl: http on loopback or an own interface, nothing else", async () => { + const { isLocalViewerUrl } = await import("../src/server/browser.ts"); + const ifaces = { eth0: [{ address: "192.168.1.5" }] } as never; + for (const ok of ["http://127.0.0.1:4573", "http://localhost:4573", "http://[::1]:4573", "http://192.168.1.5:4573"]) expect([ok, isLocalViewerUrl(ok, ifaces)]).toEqual([ok, true]); + for (const bad of ["https://127.0.0.1:4573", "http://evil.example:4573", "http://127.0.0.1", "http://u:p@127.0.0.1:4573", "http://10.0.0.9:4573", "file:///x", "not a url", "http://127.0.0.1.evil.example:4573"]) { + expect([bad, isLocalViewerUrl(bad, ifaces)]).toEqual([bad, false]); + } +}); diff --git a/test/client.changes.test.ts b/test/client.changes.test.ts index 218582a..d3d999f 100644 --- a/test/client.changes.test.ts +++ b/test/client.changes.test.ts @@ -125,7 +125,7 @@ describe("ChangesView", () => { }); test("paths are escaped and control characters made visible", async () => { - const t = make(() => data([f("x‮.ts", "M")])); + const t = make(() => data([f("x\u202e.ts", "M")])); t.v.setRepo(true); await t.v.refresh(); expect(t.list.innerHTML).not.toContain("x"); diff --git a/test/client.diffview.test.ts b/test/client.diffview.test.ts index 605234f..3e0147d 100644 --- a/test/client.diffview.test.ts +++ b/test/client.diffview.test.ts @@ -93,7 +93,7 @@ describe("spansHtml", () => { test("wraps tokens, escapes HTML and shows hidden characters", () => { expect(D.spansHtml("let x = ", [[0, 3, "k"], [8, 11, "o"]])).toBe('let x = <a>'); expect(D.spansHtml("", [])).toBe(""); - expect(D.spansHtml("a‮b", [])).toBe("a\\u202eb"); + expect(D.spansHtml("a\u202eb", [])).toBe("a\\u202eb"); expect(D.spansHtml("x".repeat(5000), [[0, 5000, "s"]]).length).toBeLessThan(4100); }); }); diff --git a/test/client.hidden.test.ts b/test/client.hidden.test.ts index c06e7d4..2e6992b 100644 --- a/test/client.hidden.test.ts +++ b/test/client.hidden.test.ts @@ -20,10 +20,10 @@ describe("AC-19 shared HIDDEN rule in util.ts", () => { "\x7f", "\x85", "\x9f", - "‪", - "‮", - "⁦", - "⁩", + "\u202a", + "\u202e", + "\u2066", + "\u2069", ]) { expect(("a" + c + "b").replace(u.HIDDEN, "#")).toBe("a#b"); } @@ -39,8 +39,8 @@ describe("AC-19 shared HIDDEN rule in util.ts", () => { test("showHidden writes escapes: \\xNN below 256, \\uNNNN above", () => { expect(u.showHidden("a\x01b")).toBe("a\\x01b"); expect(u.showHidden("a\x7fb\x85")).toBe("a\\x7fb\\x85"); - expect(u.showHidden("a‮b")).toBe("a\\u202eb"); - expect(u.showHidden("⁦x⁩")).toBe("\\u2066x\\u2069"); + expect(u.showHidden("a\u202eb")).toBe("a\\u202eb"); + expect(u.showHidden("\u2066x\u2069")).toBe("\\u2066x\\u2069"); expect(u.showHidden("plain text")).toBe("plain text"); }); @@ -55,3 +55,46 @@ describe("AC-19 shared HIDDEN rule in util.ts", () => { ); }); }); + +describe("file contents keep their tabs; names do not", () => { + test("showHiddenText leaves tabs but escapes every other HIDDEN character", () => { + expect(u.showHiddenText("\tfoo\tbar")).toBe("\tfoo\tbar"); + expect(u.showHiddenText("a\x01b\u202ec\r")).toBe("a\\x01b\\u202ec\\x0d"); + expect(u.showHidden("\tname")).toBe("\\x09name"); + }); + + test("esc escapes both quote characters", () => { + expect(u.esc(`&`)).toBe("<a title='x' b="y">&"); + }); + + test("diff lines show tabs as tabs", async () => { + const d = await import("../src/client/diffview.ts"); + expect(d.spansHtml("\tx = 1", [])).toBe("\tx = 1"); + }); + + test("the code view isolates bidi controls in place (Trojan Source) without changing the text", async () => { + const c = await import("../src/client/codeview.ts"); + const html = c.markBidi("/*\u202e } \u2066if (isAdmin)\u2069 */"); + expect(html).toBe( + '/*\u202e } \u2066if (isAdmin)\u2069 */', + ); + // Removing exactly the wrapper markBidi adds gives the input back: the text is unchanged. + const unwrapped = html.split("").join("").split(//).join(""); + expect(unwrapped).toBe("/*\u202e } \u2066if (isAdmin)\u2069 */"); + expect(c.markBidi("plain & text")).toBe("plain & text"); + }); +}); + +describe("bidi markers stay bounded (a file of U+202E lines once made 248 000 elements)", () => { + test("one marker per run, at most MAX_BIDI_MARKS per line, escapes after that", async () => { + const c = await import("../src/client/codeview.ts"); + const run = c.markBidi("a\u202e\u202e\u202eb"); + expect(run.match(/class="bidi"/g)!.length).toBe(1); + const many = c.markBidi("a\u202e".repeat(100), { left: c.MAX_BIDI_MARKS }); + expect(many.match(/class="bidi"/g)!.length).toBe(c.MAX_BIDI_MARKS); + expect(many).toContain("\\u202e"); // the rest as visible escapes + // Raw controls appear only inside the marked runs (one each here), never past the budget. + const raw = [...many].filter((ch) => /[\u202a-\u202e\u2066-\u2069]/.test(ch)).length; + expect(raw).toBe(c.MAX_BIDI_MARKS); + }); +}); diff --git a/test/client.mdcode.test.ts b/test/client.mdcode.test.ts index 5da6a77..dc1c672 100644 --- a/test/client.mdcode.test.ts +++ b/test/client.mdcode.test.ts @@ -154,32 +154,32 @@ describe("AC-18 highlighted fences", () => { describe("AC-19 Trojan Source escapes in rendered code", () => { test("code spans show controls and bidi overrides as visible escapes", () => { - const el = render("see `a‮b\u0001c` here"); + const el = render("see `a\u202eb\u0001c` here"); const code = q(el, "code")[0]!; expect(code.textContent).toBe("a\\u202eb\\x01c"); }); test("fenced code blocks (plain) show escapes, no raw characters remain", () => { - const el = render(fence("", "x‮y\u0085z⁦")); + const el = render(fence("", "x\u202ey\u0085z\u2066")); const t = blockCode(el).textContent; expect(t).toBe("x\\u202ey\\x85z\\u2066"); - expect(t).not.toMatch(/[‮\u0085⁦]/); + expect(t).not.toMatch(/[\u202e\u0085\u2066]/); }); test("highlighted fences show escapes too", () => { - const el = render(fence("js", "let a = '‮'; // ⁧\u0001")); + const el = render(fence("js", "let a = '\u202e'; // \u2067\u0001")); const t = blockCode(el).textContent; expect(t).toBe("let a = '\\u202e'; // \\u2067\\x01"); expect(spans(el).length).toBeGreaterThan(0); }); test("indented code blocks show escapes", () => { - const el = render(" a‮b"); + const el = render(" a\u202eb"); expect(blockCode(el).textContent).toBe("a\\u202eb"); }); test("link titles show escapes", () => { - const el = render('[a](https://x.y/ "t‮e\u0001")'); + const el = render('[a](https://x.y/ "t\u202ee\u0001")'); const a = q(el, "a")[0]!; expect(a.attrs.get("title")).toBe("t\\u202ee\\x01"); }); diff --git a/test/client.redos.test.ts b/test/client.redos.test.ts new file mode 100644 index 0000000..db69904 --- /dev/null +++ b/test/client.redos.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; +import { lexLine } from "../src/client/highlight.ts"; +import { headingText, symbols } from "../src/client/symbols.ts"; +import { parseMarkdown } from "../src/client/markdown.ts"; + +// Untrusted files must not freeze the UI thread: each input below took seconds before round 15. +const time = (f: () => void) => { + const t0 = performance.now(); + f(); + return performance.now() - t0; +}; + +test("JS lexer: many slashes after a long identifier stay linear (was cubic: 76 s for 10 KB)", () => { + expect(time(() => lexLine("js", "a".repeat(5000) + " /".repeat(2500), 0, []))).toBeLessThan(300); + const spans = (l: string) => { + const out: [number, number, string][] = []; + lexLine("js", l, 0, out as never); + return out.filter((s) => s[2] === "s").map((s) => l.slice(s[0], s[1])); + }; + expect(spans("return /ab+c/g.test(s);")).toEqual(["/ab+c/g"]); + expect(spans("x = typeof /x/;")).toEqual(["/x/"]); + expect(spans("x = a / b / c;")).toEqual([]); + expect(spans("x = areturn / 2 / 3;")).toEqual([]); + expect(spans("x = averyveryverylongname / 2 / 3;")).toEqual([]); +}); + +test("Go to Symbol: whitespace runs, TOML and long headings stay fast", () => { + const java = Array.from({ length: 50 }, () => "public " + " ".repeat(990) + "b"); + expect(time(() => symbols("java", java))).toBeLessThan(300); + expect(symbols("java", ["public static void main(String[] a) {"]).map((s) => s.name)).toEqual(["main"]); + const toml = Array.from({ length: 50 }, () => "[" + " ".repeat(998)); + expect(time(() => symbols("toml", toml))).toBeLessThan(300); + expect(symbols("toml", ["[ tool.x ]"]).map((s) => s.name)).toEqual(["tool.x"]); + expect(time(() => symbols("md", ["# a" + " ".repeat(40000) + "b"]))).toBeLessThan(300); + expect(time(() => headingText("[".repeat(20000)))).toBeLessThan(100); + expect(headingText("see [the *docs*](x.md) now")).toBe("see the docs now"); +}); + +test("YAML keys and bare URL trimming stay linear", () => { + expect(time(() => { for (let i = 0; i < 10; i++) lexLine("yaml", " ".repeat(9998) + "x", 0, []); })).toBeLessThan(300); + const out: [number, number, string][] = []; + lexLine("yaml", " - name : x", 0, out as never); + expect(out[0]).toEqual([4, 8, "p"]); + const src = Array.from({ length: 50 }, () => "http://a" + ")".repeat(1990)).join("\n\n"); + expect(time(() => parseMarkdown(src))).toBeLessThan(300); +}); diff --git a/test/client.search.test.ts b/test/client.search.test.ts index cc3ff4a..7322016 100644 --- a/test/client.search.test.ts +++ b/test/client.search.test.ts @@ -552,10 +552,10 @@ describe("SearchView", () => { }); test("control characters and bidi overrides are shown as visible escapes", async () => { - const bidi = "‮⁦⁩‪"; + const bidi = "\u202e\u2066\u2069\u202a"; const t = make( quick( - file(`dir‮fdp.ts`, [m(1, `a\u0001b\u001bc\u009fd${bidi}e`, 0, 1)]), + file(`dir\u202efdp.ts`, [m(1, `a\u0001b\u001bc\u009fd${bidi}e`, 0, 1)]), done({ files: 1, matches: 1 }), ), ); @@ -563,7 +563,7 @@ describe("SearchView", () => { t.enter(); await t.flush(); const html = t.results.innerHTML; - for (const raw of ["\u0001", "\u001b", "\u009f", "‮", "⁦", "⁩", "‪"]) expect(html).not.toContain(raw); + for (const raw of ["\u0001", "\u001b", "\u009f", "\u202e", "\u2066", "\u2069", "\u202a"]) expect(html).not.toContain(raw); expect(html).toContain("\\x01"); expect(html).toContain("\\x1b"); expect(html).toContain("\\x9f"); diff --git a/test/client.util.test.ts b/test/client.util.test.ts index ef3b277..33db37b 100644 --- a/test/client.util.test.ts +++ b/test/client.util.test.ts @@ -72,7 +72,7 @@ describe("store", () => { let restore = () => {}; afterEach(() => restore()); - test("init loads settings from the server, get falls back to default, set debounces a PUT", async () => { + test("init loads settings from the server, get falls back to default, set debounces a PATCH of the changed keys", async () => { const m = mockFetch((url) => (url === "/api/settings" ? Response.json({ theme: "dark" }) : new Response("{}"))); restore = m.restore; await u.store.init(); @@ -86,8 +86,9 @@ describe("store", () => { expect(m.calls.length).toBe(2); const put = m.calls[1]!; expect(put.url).toBe("/api/settings"); - expect(put.init?.method).toBe("PUT"); - expect(JSON.parse(put.init!.body as string)).toEqual({ theme: "dark", a: 1, b: 2 }); + expect(put.init?.method).toBe("PATCH"); + // Only what this window changed: another window's keys (theme here) are not sent back. + expect(JSON.parse(put.init!.body as string)).toEqual({ a: 1, b: 2 }); expect(u.store.get("a", 0)).toBe(1); }); @@ -102,6 +103,9 @@ describe("store", () => { expect(m.calls[1]!.init?.keepalive).toBe(true); await new Promise((r) => setTimeout(r, 400)); expect(m.calls.length).toBe(2); + // Nothing changed since: no request at all. + dom.window.dispatchEvent(new FakeEvent("pagehide") as unknown as Event); + expect(m.calls.length).toBe(2); }); test("init survives a failing server and a failing save", async () => { diff --git a/test/diff.test.ts b/test/diff.test.ts index 9cf3edb..555b3fa 100644 --- a/test/diff.test.ts +++ b/test/diff.test.ts @@ -68,3 +68,14 @@ describe("line diff", () => { expect(apply(a, b, h)).toEqual(b); }); }); + +test("a huge rewritten middle is one replaced hunk, quickly", async () => { + const { diffLines, MAX_DIFF_LINES } = await import("../src/client/diff.ts"); + const n = MAX_DIFF_LINES; + const a = Array.from({ length: n }, (_, i) => `old line ${i}`); + const b = Array.from({ length: n }, (_, i) => `new line ${i}`); + a.unshift("same"); b.unshift("same"); + const t0 = performance.now(); + expect(diffLines(a, b)).toEqual([{ a0: 1, a1: n + 1, b0: 1, b1: n + 1 }]); + expect(performance.now() - t0).toBeLessThan(500); +}); diff --git a/test/files.contain.test.ts b/test/files.contain.test.ts new file mode 100644 index 0000000..8045e28 --- /dev/null +++ b/test/files.contain.test.ts @@ -0,0 +1,40 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { openRegular, readForView } from "../src/server/files.ts"; +import { FileIndex } from "../src/server/index.ts"; + +let root = ""; +let outside = ""; +beforeAll(async () => { + root = await realpath(await mkdtemp(path.join(tmpdir(), "cv-contain-"))); + outside = await realpath(await mkdtemp(path.join(tmpdir(), "cv-contain-out-"))); + await mkdir(path.join(outside, "d")); + await writeFile(path.join(outside, "d", "f"), "OUTSIDE"); + await mkdir(path.join(root, "in")); + await writeFile(path.join(root, "in", "f"), "inside"); + // What a parent swapped for a symlink after resolveSafe() looks like to open(). + await symlink(path.join(outside, "d"), path.join(root, "race")); +}); +afterAll(async () => { + await rm(root, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); +}); + +test("openRegular with a root refuses a file reached through a parent symlink out of it", async () => { + await expect(openRegular(path.join(root, "race", "f"), root)).rejects.toThrow("file changed while opening"); + await expect(readForView(path.join(root, "race", "f"), false, root)).rejects.toThrow(); + const ok = await openRegular(path.join(root, "in", "f"), root); + await ok.fh.close(); +}); + +test("FileIndex: a file created while the index is being built is not lost", async () => { + const ix = new FileIndex(root); + const first = ix.get(); + await writeFile(path.join(root, "NEW.txt"), ""); + // The watcher reports it while the build is still walking (files not yet stored). + await ix.apply(["NEW.txt"]); + await first; + expect((await ix.get()).split("\n")).toContain("NEW.txt"); +}); diff --git a/test/files.resolve.fuzz.test.ts b/test/files.resolve.fuzz.test.ts new file mode 100644 index 0000000..fee64f2 --- /dev/null +++ b/test/files.resolve.fuzz.test.ts @@ -0,0 +1,82 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { openRegular, resolveSafe, within } from "../src/server/files.ts"; + +// Containment, as a property: whatever relative path a client sends, through whatever symlinks the +// tree holds, resolveSafe answers null or a real path inside the root, and nothing it lets through +// opens a file outside. Seeded and deterministic. + +let base = ""; +let root = ""; +let outside = ""; + +beforeAll(async () => { + base = await realpath(await mkdtemp(path.join(tmpdir(), "cv-resolve-"))); + root = path.join(base, "root"); + outside = path.join(base, "outside"); + await mkdir(path.join(root, "a", "b"), { recursive: true }); + await mkdir(outside); + await writeFile(path.join(outside, "secret.txt"), "OUTSIDE"); + await writeFile(path.join(root, "a", "b", "in.txt"), "inside"); + await writeFile(path.join(root, "top.txt"), "inside"); + await symlink(outside, path.join(root, "out-dir")); + await symlink(path.join(outside, "secret.txt"), path.join(root, "out-file")); + await symlink("../outside/secret.txt", path.join(root, "a", "rel-out")); + await symlink("/", path.join(root, "slash")); + await symlink(path.join(root, "a"), path.join(root, "in-dir")); + await symlink("b/in.txt", path.join(root, "a", "in-file")); + await symlink("loop2", path.join(root, "loop1")); + await symlink("loop1", path.join(root, "loop2")); + await symlink(base, path.join(root, "a", "up")); + await symlink("nowhere", path.join(root, "dangling")); +}); + +afterAll(async () => { + await rm(base, { recursive: true, force: true }); +}); + +const SEGMENTS = [ + "a", "b", "in.txt", "top.txt", "out-dir", "out-file", "rel-out", "slash", "in-dir", "in-file", "loop1", "dangling", "up", "secret.txt", + "outside", "root", "..", ".", "", "etc", "passwd", "%2e%2e", "..%2f", "\\..", "a\\..\\..", "\0", "~", "C:", "//", " ", "..." +]; + +function rng(seed: number) { + let s = seed >>> 0 || 1; + return () => ((s = Math.imul(s ^ (s >>> 15), 0x2c1b3c6d) + 0x6d2b79f5) >>> 0) / 2 ** 32; +} + +test("5000 random relative paths never resolve, or open, outside the root", async () => { + const r = rng(42); + let resolved = 0; + for (let k = 0; k < 5000; k++) { + const n = 1 + Math.floor(r() * 5); + const parts: string[] = []; + for (let i = 0; i < n; i++) parts.push(SEGMENTS[Math.floor(r() * SEGMENTS.length)]!); + const rel = parts.join(r() < 0.8 ? "/" : r() < 0.5 ? "\\" : "//") + (r() < 0.1 ? "/" : ""); + const abs = await resolveSafe(root, r() < 0.05 ? "/" + rel : rel); + if (abs === null) continue; + resolved++; + if (!within(root, abs)) throw new Error(`${JSON.stringify(rel)} resolved outside: ${abs}`); + if (realpathSync(abs) !== abs) throw new Error(`${JSON.stringify(rel)} resolved to a non-real path ${abs}`); + const opened = await openRegular(abs, root).catch(() => null); + if (opened) { + const text = new TextDecoder().decode((await opened.fh.read(new Uint8Array(64), 0, 64, 0)).buffer.subarray(0, 64)); + await opened.fh.close(); + if (text.includes("OUTSIDE")) throw new Error(`${JSON.stringify(rel)} opened an outside file`); + } + } + // The generator does reach real files (the property is not vacuously true). + expect(resolved).toBeGreaterThan(100); +}); + +test("known escapes are refused one by one", async () => { + for (const rel of ["out-dir/secret.txt", "out-file", "a/rel-out", "slash/etc/passwd", "a/up/outside/secret.txt", "../outside/secret.txt", "a/../../outside/secret.txt", "/etc/passwd", "a\\..\\..\\outside", "loop1", "dangling", "a/b/in.txt\0x"]) { + expect([rel, await resolveSafe(root, rel)]).toEqual([rel, null]); + } + // In-root symlinks are fine. + expect(await resolveSafe(root, "in-dir/b/in.txt")).toBe(path.join(root, "a", "b", "in.txt")); + expect(await resolveSafe(root, "a/in-file")).toBe(path.join(root, "a", "b", "in.txt")); +}); diff --git a/test/fuzzy.test.ts b/test/fuzzy.test.ts index 6f642a7..f85fb30 100644 --- a/test/fuzzy.test.ts +++ b/test/fuzzy.test.ts @@ -22,12 +22,20 @@ test("searchPaths: same top results as a full sort, narrowing on longer queries, const files = Array.from({ length: 200_000 }, (_, i) => `src/pkg${i % 997}/module_${i}/file${i}.ts`); files.push("src/server/index.ts", "src/client/term/terminal.ts"); const lower = files.map((p) => p.toLowerCase()); - let t = performance.now(); - const a = searchPaths("ser", files, lower, null); - const first = performance.now() - t; - t = performance.now(); - const b = searchPaths("serind", files, lower, a.hits); - const narrowed = performance.now() - t; + // Best of five for each: one run apiece compared two noisy samples (it failed 1 run in 3 here). + const best = (f: () => T): [T, number] => { + let out!: T; + let ms = Infinity; + for (let r = 0; r < 5; r++) { + Bun.gc(true); + const t = performance.now(); + out = f(); + ms = Math.min(ms, performance.now() - t); + } + return [out, ms]; + }; + const [a, first] = best(() => searchPaths("ser", files, lower, null)); + const [b, narrowed] = best(() => searchPaths("serind", files, lower, a.hits)); expect(b.best[0]!.p).toBe("src/server/index.ts"); expect(narrowed).toBeLessThan(first); expect(first).toBeLessThan(150 * BUDGET); diff --git a/test/git.hostile-config.test.ts b/test/git.hostile-config.test.ts new file mode 100644 index 0000000..4130a6f --- /dev/null +++ b/test/git.hostile-config.test.ts @@ -0,0 +1,194 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, readdir, realpath, rm, stat, utimes, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Git } from "../src/server/git.ts"; + +// One repository that names a program in every config key and hook git might consult, with files in +// every state (modified, stat-only dirty, untracked, renamed, binary, conflicted-free). Every Git +// method runs against it; none may run a program, and none may write anything under .git. +// A key or hook added to git later that is not neutralised shows up as the marker file. + +let dir = ""; +let repo = ""; +let marker = ""; +let evil = ""; + +const sh = (args: string[], cwd = repo) => { + const r = Bun.spawnSync(["git", "-c", "core.hooksPath=/dev/null", ...args], { cwd, stderr: "pipe", env: { ...process.env, GIT_AUTHOR_NAME: "a", GIT_AUTHOR_EMAIL: "a@b", GIT_COMMITTER_NAME: "a", GIT_COMMITTER_EMAIL: "a@b" } }); + if (r.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr}`); + return r.stdout.toString(); +}; + +/** Every file under .git with its size and mtime: any write by codeviewer changes this. */ +async function snapshot(root: string): Promise { + const out: string[] = []; + const walk = async (d: string) => { + for (const e of await readdir(d, { withFileTypes: true })) { + const p = path.join(d, e.name); + if (e.isDirectory()) await walk(p); + else { + const s = await stat(p); + out.push(`${path.relative(root, p)} ${s.size} ${s.mtimeMs}`); + } + } + }; + await walk(root); + return out.sort().join("\n"); +} + +const HOOKS = [ + "post-index-change", "pre-commit", "post-checkout", "post-merge", "pre-auto-gc", "post-rewrite", "reference-transaction", + "fsmonitor-watchman", "pre-push", "post-commit", "push-to-checkout", "sendemail-validate", "post-update", "update", +]; + +beforeAll(async () => { + dir = await realpath(await mkdtemp(path.join(tmpdir(), "cv-hostile-"))); + repo = path.join(dir, "repo"); + marker = path.join(dir, "PWNED"); + evil = path.join(dir, "evil.sh"); + await writeFile(evil, `#!/bin/sh\necho "$0 $*" >> '${marker}'\ncat 2>/dev/null\nexit 0\n`); + await chmod(evil, 0o755); + await mkdir(repo); + sh(["init", "-q", "-b", "main"]); + await writeFile(path.join(repo, "a.txt"), "one\ntwo\n"); + await writeFile(path.join(repo, "same.txt"), "unchanged\n"); + await writeFile(path.join(repo, "bin.dat"), new Uint8Array([0, 1, 2, 3, 0, 9])); + await writeFile(path.join(repo, "old.md"), "# title\n\nbody text that is long enough to be detected as a rename\n"); + sh(["add", "."]); + sh(["commit", "-qm", "init"]); + sh(["branch", "feature"]); + sh(["checkout", "-q", "feature"]); + await writeFile(path.join(repo, "a.txt"), "one\nTWO\n"); + sh(["commit", "-qam", "on feature"]); + + // Working tree states (made before the hostile config, which would trip these setup commands): a real edit, an untracked file, a rename, a binary edit, and files whose + // stat info changed but content did not (what an extracted tarball looks like: git wants to + // refresh the index for these, which writes it and runs post-index-change). + await writeFile(path.join(repo, "a.txt"), "one\nTWO\nthree\n"); + await writeFile(path.join(repo, "untracked.txt"), "new\n"); + sh(["-c", "core.hooksPath=/dev/null", "mv", "old.md", "new.md"]); + await writeFile(path.join(repo, "bin.dat"), new Uint8Array([0, 1, 2, 3, 0, 9, 9])); + + // Every hook, in .git/hooks and in a tracked folder named by core.hooksPath. + await mkdir(path.join(repo, ".git", "hooks"), { recursive: true }); + await mkdir(path.join(repo, "hooks")); + for (const h of HOOKS) { + for (const d of [path.join(repo, ".git", "hooks"), path.join(repo, "hooks")]) { + await writeFile(path.join(d, h), `#!/bin/sh\n'${evil}' hook ${h}\n`); + await chmod(path.join(d, h), 0o755); + } + } + // Every config key known to name a program (or a file read in full). + const keys: [string, string][] = [ + ["core.hooksPath", path.join(repo, "hooks")], ["core.fsmonitor", evil], ["core.sshCommand", evil], ["core.askPass", evil], + ["core.editor", evil], ["core.pager", evil], ["sequence.editor", evil], ["gpg.program", evil], ["gpg.ssh.program", evil], + ["gpg.x509.program", evil], ["credential.helper", `!${evil}`], ["interactive.diffFilter", evil], ["diff.external", evil], + ["diff.evil.textconv", evil], ["diff.evil.command", evil], ["diff.evil.cachetextconv", "true"], ["filter.evil.clean", evil], + ["filter.evil.smudge", evil], ["filter.evil.process", evil], ["filter.evil.required", "true"], ["merge.evil.driver", evil], + ["core.alternateRefsCommand", evil], ["uploadpack.packObjectsHook", evil], ["remote.origin.url", `ext::${evil}`], + ["remote.origin.uploadpack", evil], ["protocol.allow", "always"], ["protocol.ext.allow", "always"], ["log.showSignature", "true"], + ["status.showUntrackedFiles", "all"], ["diff.renames", "copies"], ["diff.renameLimit", "0"], ["core.untrackedCache", "true"], + ["core.splitIndex", "true"], ["gc.auto", "1"], ["maintenance.auto", "true"], ["fetch.writeCommitGraph", "true"], + ["core.commitGraph", "true"], ["feature.manyFiles", "true"], ["index.threads", "4"], ["blame.ignoreRevsFile", "/dev/zero"], + ["mailmap.file", "/dev/zero"], ["diff.orderFile", "/dev/zero"], ["core.excludesFile", path.join(repo, "hooks", "pre-commit")], + ["i18n.logOutputEncoding", "UTF-16"], ["core.preloadIndex", "true"], ["pager.status", evil], ["pager.diff", evil], ["pager.log", evil], + ]; + for (const [k, v] of keys) sh(["config", k, v]); + // Attributes from every source git reads, binding every path to the hostile drivers. + await writeFile(path.join(repo, ".gitattributes"), "* filter=evil diff=evil merge=evil\n"); + await mkdir(path.join(repo, ".git", "info"), { recursive: true }); + await writeFile(path.join(repo, ".git", "info", "attributes"), "* filter=evil diff=evil merge=evil\n"); + + const later = new Date(Date.now() + 60_000); + await utimes(path.join(repo, "same.txt"), later, later); + await rm(marker, { force: true }); +}); + +afterAll(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +test("no git call runs a program, and nothing under .git is written", async () => { + const before = await snapshot(path.join(repo, ".git")); + const g = (await Git.open(repo))!; + expect(g).not.toBeNull(); + await g.status(); + const head = await g.changes("head"); + const branch = await g.changes("branch"); + await g.listFiles(); + await g.base("a.txt", 1 << 20); + await g.blame("a.txt", 2); + await g.history("a.txt"); + await g.history("new.md"); + g.stop(); + await Bun.sleep(200); // anything spawned in the background would have written by now + expect(existsSync(marker) ? await Bun.file(marker).text() : "").toBe(""); + expect(await snapshot(path.join(repo, ".git"))).toBe(before); + + // And the answers are still right: real edits listed, the stat-only file not. + const paths = (c: typeof head) => c.files.map((f) => `${f.status} ${f.path}`).sort(); + expect(paths(head)).toContain("M a.txt"); + expect(paths(head)).toContain("M bin.dat"); + expect(paths(head)).toContain("? untracked.txt"); + expect(paths(head).some((p) => p.endsWith(" new.md"))).toBe(true); + expect(paths(branch)).toContain("M a.txt"); + expect(paths(head).some((p) => p.endsWith("same.txt"))).toBe(false); +}, 60_000); + +test("special files in .git (a /dev/zero symlink, a FIFO) turn git off instead of exhausting memory", async () => { + const { symlink } = await import("node:fs/promises"); + for (const [file, how] of [ + ["config", "zero"], ["HEAD", "zero"], ["commondir", "zero"], ["objects/info/alternates", "zero"], ["info/grafts", "zero"], + ["info/attributes", "fifo"], ["info/exclude", "fifo"], [".git", "fifo"], + ] as const) { + const r = path.join(dir, `special-${file.replace(/\W/g, "_")}`); + await mkdir(r); + sh(["init", "-q"], r); + await writeFile(path.join(r, "f.txt"), "x\n"); + sh(["add", "."], r); + sh(["commit", "-qm", "i"], r); + const target = file === ".git" ? path.join(r, ".git") : path.join(r, ".git", ...file.split("/")); + await rm(target, { recursive: true, force: true }); + await mkdir(path.dirname(target), { recursive: true }); + if (how === "zero") await symlink("/dev/zero", target); + else Bun.spawnSync(["mkfifo", target]); + const t0 = performance.now(); + const g = await Git.open(r); + if (g) { + await g.status(); + await g.changes("head"); + await g.blame("f.txt", 1); + g.stop(); + } + expect([file, performance.now() - t0 < 5000]).toEqual([file, true]); + } +}, 120_000); + +test("git variables inherited from the environment cannot point at another repository", async () => { + const other = path.join(dir, "other"); + await mkdir(other); + sh(["init", "-q"], other); + await writeFile(path.join(other, "secret.txt"), "PRIVATE\n"); + sh(["add", "."], other); + sh(["commit", "-qm", "s"], other); + const plain = path.join(dir, "plain"); + await mkdir(plain); + await writeFile(path.join(plain, "secret.txt"), "x\n"); + // ENV is built when git.ts loads: run in a child with GIT_DIR set, as a hook would. + const script = `import { Git } from ${JSON.stringify(path.join(import.meta.dir, "..", "src", "server", "git.ts"))}; +const g = await Git.open(${JSON.stringify(plain)}); console.log(g ? "repo" : "none");`; + const p = Bun.spawnSync(["bun", "-e", script], { env: { ...process.env, GIT_DIR: path.join(other, ".git"), GIT_WORK_TREE: plain } }); + expect(p.stdout.toString().trim()).toBe("none"); +}); + +test("an aborted blame stops at once (its git process is killed)", async () => { + const g = (await Git.open(repo))!; + const ac = new AbortController(); + ac.abort(); + const t0 = performance.now(); + expect(await g.blame("a.txt", 1, ac.signal)).toBeNull(); + expect(performance.now() - t0).toBeLessThan(1000); + g.stop(); +}); diff --git a/test/git.lazyfetch.test.ts b/test/git.lazyfetch.test.ts new file mode 100644 index 0000000..65235c8 --- /dev/null +++ b/test/git.lazyfetch.test.ts @@ -0,0 +1,166 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, rm, realpath, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Git } from "../src/server/git.ts"; + +// A partial clone fetches missing objects from its promisor remote on demand, and the remote's +// config names a program (remote.origin.uploadpack). Reading such a repository must never run it. +let dir = ""; +let victim = ""; +let marker = ""; +let oldOid = ""; + +const sh = (cmd: string[], cwd: string) => { + const r = Bun.spawnSync(["git", ...cmd], { cwd, stderr: "pipe" }); + if (r.exitCode !== 0) throw new Error(`git ${cmd.join(" ")}: ${r.stderr}`); + return r.stdout.toString(); +}; + +beforeAll(async () => { + dir = await realpath(await mkdtemp(path.join(tmpdir(), "cv-lazy-"))); + const src = path.join(dir, "src"); + victim = path.join(dir, "victim"); + marker = path.join(dir, "pwned"); + await mkdir(src); + sh(["init", "-q", "-b", "main"], src); + sh(["config", "uploadpack.allowFilter", "true"], src); + sh(["config", "user.email", "a@b"], src); + sh(["config", "user.name", "a"], src); + await writeFile(path.join(src, "a.txt"), "one\n"); + sh(["add", "a.txt"], src); + sh(["commit", "-qm", "one"], src); + oldOid = sh(["rev-parse", "HEAD"], src).trim(); + await writeFile(path.join(src, "a.txt"), "two\n"); + sh(["commit", "-qam", "two"], src); + // Blobless clone: only HEAD's blobs are present after checkout; older ones are fetched lazily. + sh(["clone", "-q", "--no-local", "--filter=blob:none", `file://${src}`, victim], dir); + sh(["config", "remote.origin.uploadpack", `touch '${marker}'; false`], victim); + sh(["config", "protocol.file.allow", "always"], victim); + sh(["config", "protocol.allow", "always"], victim); +}); + +afterAll(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +test("a partial clone's promisor remote is never contacted (no lazy fetch runs its programs)", async () => { + const g = (await Git.open(victim))!; + expect(g).not.toBeNull(); + // The old blob is missing locally: reading it must fail instead of fetching. + expect(await g.base("a.txt", 1 << 20, oldOid)).toBeNull(); + await g.history("a.txt"); + await g.blame("a.txt", 1); + await g.changes("head"); + g.stop(); + expect(existsSync(marker)).toBe(false); +}); + +test("a viewed folder whose name starts with '..' is still inside the repository", async () => { + const sub = path.join(victim, "..odd"); + await mkdir(sub); + await writeFile(path.join(sub, "f.txt"), "x\n"); + const g = await Git.open(sub); + expect(g).not.toBeNull(); + g?.stop(); +}); + +test("a hostile blame.ignoreRevsFile neither breaks nor hangs blame", async () => { + // The full repository: blame in the blobless clone would need a parent blob it never fetches. + const src = path.join(dir, "src"); + for (const file of [path.join(dir, "missing"), "/dev/zero"]) { + Bun.spawnSync(["git", "config", "blame.ignoreRevsFile", file], { cwd: src }); + const g = (await Git.open(src))!; + const t0 = performance.now(); + const b = await g.blame("a.txt", 1); + g.stop(); + expect(b?.hash).toMatch(/^[0-9a-f]{40}$/); + expect(performance.now() - t0).toBeLessThan(5000); + } + Bun.spawnSync(["git", "config", "--unset", "blame.ignoreRevsFile"], { cwd: src }); +}); + +test("git calls fail cleanly (no throw) once the viewed folder is deleted", async () => { + const gone = path.join(dir, "gone"); + await mkdir(gone); + sh(["init", "-q"], gone); + const g = (await Git.open(gone))!; + await rm(gone, { recursive: true, force: true }); + // Bun.spawn throws on a missing cwd; from the status refresh timer that exited the server. + const s = await g.status(); + expect(s.repo).toBe(true); + expect(await g.blame("f", 1)).toBeNull(); + g.stop(); +}); + +describe("round 15, second pass: configs that reach past the overrides", () => { + const evil = () => path.join(dir, "evil2.sh"); + const marker2 = () => path.join(dir, "pwned2"); + beforeAll(async () => { + await writeFile(evil(), `#!/bin/sh\ntouch '${marker2()}'\ncat\n`); + Bun.spawnSync(["chmod", "+x", evil()]); + }); + + test("a submodule's own filter never runs (git does not look inside submodules)", async () => { + const sub = path.join(dir, "subsrc"); + const top = path.join(dir, "withsub"); + await mkdir(sub); + sh(["init", "-q"], sub); + await writeFile(path.join(sub, "s.txt"), "x\n"); + sh(["add", "."], sub); + sh(["-c", "user.email=a@b", "-c", "user.name=a", "commit", "-qm", "s"], sub); + await mkdir(top); + sh(["init", "-q"], top); + sh(["-c", "protocol.file.allow=always", "submodule", "add", "-q", sub, "sm"], top); + sh(["-c", "user.email=a@b", "-c", "user.name=a", "commit", "-qm", "i"], top); + sh(["config", "-f", path.join(top, ".git", "modules", "sm", "config"), "filter.evil.clean", evil()], top); + await mkdir(path.join(top, ".git", "modules", "sm", "info"), { recursive: true }); + await writeFile(path.join(top, ".git", "modules", "sm", "info", "attributes"), "* filter=evil\n"); + await writeFile(path.join(top, "sm", "s.txt"), "z\n"); + const g = (await Git.open(top))!; + await g.changes("head"); + await g.status(); + g.stop(); + expect(existsSync(marker2())).toBe(false); + }); + + test("a driver added to an included file after the first call is still blanked", async () => { + const r = path.join(dir, "inc"); + await mkdir(r); + sh(["init", "-q"], r); + await writeFile(path.join(r, "f.txt"), "hello\n"); + await writeFile(path.join(r, "inc.cfg"), ""); + sh(["add", "."], r); + sh(["-c", "user.email=a@b", "-c", "user.name=a", "commit", "-qm", "i"], r); + sh(["config", "include.path", "../inc.cfg"], r); + await writeFile(path.join(r, ".git", "info", "attributes"), "f.txt filter=late\n"); + const g = (await Git.open(r))!; + await g.changes("head"); + await writeFile(path.join(r, "inc.cfg"), `[filter "late"]\n\tclean = ${evil()}\n`); + await writeFile(path.join(r, "f.txt"), "hellp\n"); + await g.changes("head"); + g.stop(); + expect(existsSync(marker2())).toBe(false); + }); + + test("history and blame read UTF-8 whatever i18n.logOutputEncoding says; log.showRoot=false keeps the root", async () => { + const r = path.join(dir, "enc"); + await mkdir(r); + sh(["init", "-q"], r); + await writeFile(path.join(r, "f"), "1\n"); + sh(["add", "f"], r); + sh(["-c", "user.email=a@b", "-c", "user.name=Zoë", "commit", "-qm", "café"], r); + await writeFile(path.join(r, "f"), "2\n"); + sh(["-c", "user.email=a@b", "-c", "user.name=Zoë", "commit", "-qam", "two"], r); + sh(["config", "i18n.logOutputEncoding", "UTF-16"], r); + sh(["config", "log.showRoot", "false"], r); + const g = (await Git.open(r))!; + const h = (await g.history("f"))!; + expect(h.map((e) => e.subject)).toEqual(["two", "café"]); + expect(h[1]!.status).toBe("A"); + expect((await g.blame("f", 1))?.author).toBe("Zoë"); + expect((await g.status()).head?.author).toBe("Zoë"); + g.stop(); + }); +}); diff --git a/test/http.coverage.test.ts b/test/http.coverage.test.ts index 98b4add..1325b4d 100644 --- a/test/http.coverage.test.ts +++ b/test/http.coverage.test.ts @@ -529,3 +529,91 @@ test("/api/raw fails the body instead of sending fewer bytes than content-length expect(failed).toBe(true); expect((await get("/api/info")).status).toBe(200); }); + +test("round 15: no Bun error page, own-key MIME lookups, whole-byte limits, files-only open", async () => { + // A Host header that makes the URL unparseable: a JSON 400/421, never an HTML page with paths. + const raw = await new Promise((resolve, reject) => { + let out = ""; + void Bun.connect({ + hostname: "127.0.0.1", + port, + socket: { + open(s) { + s.write("GET /api/info HTTP/1.1\r\nHost: [\r\nConnection: close\r\n\r\n"); + }, + data(_s, d) { + out += new TextDecoder().decode(d); + }, + close() { + resolve(out); + }, + error(_s, e) { + reject(e); + }, + }, + }); + }); + expect(raw).not.toContain("text/html"); + expect(raw).not.toContain(process.cwd()); + expect(raw).toMatch(/^HTTP\/1\.1 4\d\d/); + + // Prototype keys are not extensions. + await writeFile(path.join(dir, "z.constructor"), "x"); + await writeFile(path.join(dir, "y.__proto__"), "x"); + expect((await get("/api/raw?path=z.constructor")).headers.get("content-type")).toBe("application/octet-stream"); + expect((await get("/api/file?path=y.__proto__")).headers.get("x-cv-kind")).toBe("text"); + + // A fractional limit is a whole number of bytes, and the body matches it. + const lim = await get("/api/raw?path=noext&limit=1.5"); + expect(await lim.text()).toBe("x"); + + // /api/open: regular files only (not the root, not a folder). + const post = (body: unknown) => fetch(`${base}/api/open`, { method: "POST", headers: json, body: JSON.stringify(body) }); + expect((await post({ path: dir })).status).toBe(404); + expect((await post({ path: "sub" })).status).toBe(404); +}); + +test("round 15: settings refuse deep nesting (indented output grows with depth squared)", async () => { + const put = (body: string) => fetch(`${base}/api/settings`, { method: "PUT", headers: json, body }); + const deep = `{"a":${"[".repeat(3000)}${"]".repeat(3000)}}`; + expect((await put(deep)).status).toBe(400); + expect((await readFile(path.join(cfg, "codeviewer", "settings.json"), "utf8").catch(() => "")).length).toBeLessThan(100_000); + expect((await put(JSON.stringify({ tabs: [{ path: "a" }], nested: { a: { b: { c: 1 } } } }))).status).toBe(200); +}); + +test("round 15: on POSIX a backslash is part of a file name, not a separator", async () => { + if (process.platform === "win32") return; + await writeFile(path.join(dir, "sub\\b.ts"), "BACKSLASH"); + expect(await (await get(`/api/file?path=${encodeURIComponent("sub\\b.ts")}`)).text()).toBe("BACKSLASH"); +}); + +test("PATCH /api/settings merges changed keys, null removes one, and old tabs: roots are pruned", async () => { + const patch = (body: unknown) => fetch(`${base}/api/settings`, { method: "PATCH", headers: json, body: JSON.stringify(body) }); + await fetch(`${base}/api/settings`, { method: "PUT", headers: json, body: JSON.stringify({ theme: "dark", "tabs:/a": { tabs: ["x"] } }) }); + // Two windows, each sending only its own change: neither loses the other's. + expect((await patch({ "tabs:/b": { tabs: ["y"] } })).status).toBe(200); + expect((await patch({ theme: "light" })).status).toBe(200); + expect(await (await get("/api/settings")).json()).toEqual({ theme: "light", "tabs:/a": { tabs: ["x"] }, "tabs:/b": { tabs: ["y"] } }); + expect((await patch({ theme: null })).status).toBe(200); + expect("theme" in (await (await get("/api/settings")).json())).toBe(false); + const many: Record = {}; + for (let i = 0; i < 150; i++) many[`tabs:/r${i}`] = { tabs: [] }; + await patch(many); + const after = (await (await get("/api/settings")).json()) as Record; + const roots = Object.keys(after).filter((k) => k.startsWith("tabs:")); + expect(roots.length).toBe(100); + expect(roots.at(-1)).toBe("tabs:/r149"); + expect((await patch([1])).status).toBe(400); + expect((await patch(JSON.parse(`{"a":${"[".repeat(100)}${"]".repeat(100)}}`))).status).toBe(400); + await fetch(`${base}/api/settings`, { method: "PUT", headers: json, body: "{}" }); +}); + +test("PATCH /api/settings stores prototype-named keys as plain data", async () => { + const patch = (body: string) => fetch(`${base}/api/settings`, { method: "PATCH", headers: json, body }); + expect((await patch('{"__proto__":{"polluted":1},"constructor":"x","toString":2}')).status).toBe(200); + expect(({} as Record).polluted).toBeUndefined(); + const s = JSON.parse(await (await get("/api/settings")).text()); + expect(Object.hasOwn(s, "constructor")).toBe(true); + expect(s.toString).toBe(2); + await fetch(`${base}/api/settings`, { method: "PUT", headers: json, body: "{}" }); +}); diff --git a/test/http.routes.test.ts b/test/http.routes.test.ts new file mode 100644 index 0000000..6f60c61 --- /dev/null +++ b/test/http.routes.test.ts @@ -0,0 +1,189 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { startServer } from "../src/server/http.ts"; + +// Every route the server has, discovered from its source: a route added later is covered by the +// same auth, Host, CSRF and header rules without anyone remembering to add it here. + +const fetch = globalThis.fetch.bind(globalThis) as typeof globalThis.fetch; +const token = "R".repeat(43); +let dir = ""; +let cfg = ""; +let app: ReturnType; +let base = ""; +let port = 0; +let ROUTES: string[] = []; +const saved: Record = {}; + +const fake = { + install: { kind: "source" }, + installing: false, + check: async () => ({ current: "0", latest: null, available: false, kind: "source", canInstall: false, checkedAt: 0 }), + apply: async () => { + throw new Error("not in tests"); + }, +}; + +beforeAll(async () => { + for (const k of ["XDG_CONFIG_HOME", "CODEVIEWER_NO_UPDATE_CHECK"]) saved[k] = process.env[k]; + cfg = await realpath(await mkdtemp(path.join(tmpdir(), "cv-routes-cfg-"))); + process.env.XDG_CONFIG_HOME = cfg; + process.env.CODEVIEWER_NO_UPDATE_CHECK = "1"; + dir = await realpath(await mkdtemp(path.join(tmpdir(), "cv-routes-"))); + await writeFile(path.join(dir, "a.txt"), "hello\n"); + app = startServer({ root: dir, port: 0, host: "127.0.0.1", shell: "/bin/sh", token, openExternal: () => {}, updater: fake as never }); + port = app.server.port!; + base = `http://127.0.0.1:${port}`; + const src = await readFile(path.join(import.meta.dir, "..", "src", "server", "http.ts"), "utf8"); + ROUTES = [...new Set([...src.matchAll(/case "(\/api\/[^"]+)":/g)].map((m) => m[1]!))]; +}); + +afterAll(async () => { + app.stop(); + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + await rm(dir, { recursive: true, force: true }); + await rm(cfg, { recursive: true, force: true }); +}); + +const expectSecurityHeaders = (r: Response, what: string) => { + const ctx = `${what}: ${r.status}`; + expect([ctx, r.headers.get("x-content-type-options")]).toEqual([ctx, "nosniff"]); + expect([ctx, r.headers.get("x-frame-options")]).toEqual([ctx, "DENY"]); + expect([ctx, r.headers.get("referrer-policy")]).toEqual([ctx, "no-referrer"]); + expect([ctx, (r.headers.get("content-type") ?? "").includes("text/html")]).toEqual([ctx, false]); +}; + +const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const; + +describe("every API route", () => { + test("the route list was found (the source format is still what this test parses)", () => { + expect(ROUTES.length).toBeGreaterThanOrEqual(15); + for (const r of ["/api/file", "/api/raw", "/api/open", "/api/settings", "/api/update/install"]) expect(ROUTES).toContain(r); + }); + + test("refuses every method without the session token: 401 JSON with the security headers", async () => { + for (const p of ROUTES) { + for (const method of METHODS) { + const r = await fetch(`${base}${p}`, { method, headers: { "content-type": "application/json" }, body: method === "GET" ? undefined : "{}" }); + expect([p, method, r.status]).toEqual([p, method, 401]); + expectSecurityHeaders(r, `${method} ${p}`); + expect(await r.json()).toEqual({ error: "unauthorized" }); + } + } + }); + + test("a token in the query string, a cookie or a wrong token is not a credential", async () => { + for (const p of ROUTES) { + for (const headers of [{}, { cookie: `t=${token}` }, { "x-cv-token": token.slice(1) }, { "x-cv-token": token + "x" }, { "x-cv-token": "" }]) { + const r = await fetch(`${base}${p}?t=${token}&token=${token}`, { headers }); + expect([p, r.status]).toEqual([p, 401]); + } + } + }); + + test("the terminal's open-only token reaches POST /api/open and nothing else", async () => { + for (const p of ROUTES) { + for (const method of METHODS) { + const r = await fetch(`${base}${p}`, { + method, + headers: { "x-cv-token": app.openToken, "content-type": "application/json" }, + body: method === "GET" ? undefined : JSON.stringify({ path: "a.txt" }), + }); + const allowed = p === "/api/open"; + expect([p, method, r.status === 401]).toEqual([p, method, !allowed]); + } + } + // Prefix and normalization tricks do not stretch it either. + for (const p of ["//api/open", "/api/open/", "/api/open/../info", "/API/OPEN", "/api/%6fpen", "/ws/../api/info", "/api/open?x=/api/info"]) { + const r = await fetch(`${base}${p}`, { method: "POST", headers: { "x-cv-token": app.openToken, "content-type": "application/json" }, body: "{}" }); + // Only a request that the server routes to /api/open gets past auth (then 400: no path). + // Bun collapses repeated slashes, so "//api/open" is that route too; nothing else is. + const routed = new URL(base + p).pathname.replace(/\/{2,}/g, "/") === "/api/open"; + expect([p, r.status]).toEqual([p, routed ? 400 : 401]); + const info = await fetch(`${base}${p}`, { headers: { "x-cv-token": app.openToken } }); + expect([p, info.status === 200 && (await info.text()).includes('"root"')]).toEqual([p, false]); + } + }); + + test("a foreign Host header is refused before anything else (DNS rebinding)", async () => { + for (const p of ["/", "/app.js", ...ROUTES, "/ws/events"]) { + for (const host of [`evil.example:${port}`, `127.0.0.1.evil.example:${port}`, `localhost`, `127.0.0.1:${port + 1}`, `[::ffff:127.0.0.1]:${port}`]) { + const r = await fetch(`${base}${p}`, { headers: { "x-cv-token": token, host } }); + expect([p, host, r.status]).toEqual([p, host, 421]); + } + } + }); + + test("writes need application/json (a cross-site form or no-cors fetch cannot send it)", async () => { + for (const p of ROUTES) { + for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { + for (const type of [null, "text/plain", "application/x-www-form-urlencoded", "multipart/form-data; boundary=x", "text/plain; charset=application/json"]) { + const headers: Record = { "x-cv-token": token }; + if (type) headers["content-type"] = type; + const r = await fetch(`${base}${p}`, { method, headers, body: "{}" }); + expect([p, method, type, r.status]).toEqual([p, method, type, 415]); + expectSecurityHeaders(r, `${method} ${p} (${type})`); + } + } + } + }); + + test("authenticated GETs never fail with a 5xx or an HTML page, and carry the security headers", async () => { + for (const p of ROUTES) { + const r = await fetch(`${base}${p}`, { headers: { "x-cv-token": token } }); + expect([p, r.status < 500]).toEqual([p, true]); + expectSecurityHeaders(r, `GET ${p}`); + await r.arrayBuffer(); + } + }); + + test("unknown paths are a JSON 404 after auth, never a fallthrough to the app shell", async () => { + for (const p of ["/api/nope", "/api/file/x", "/api", "/api/", "/ws/nope", "/index.html/x"]) { + const r = await fetch(`${base}${p}`, { headers: { "x-cv-token": token } }); + expect([p, r.status]).toEqual([p, 404]); + expectSecurityHeaders(r, `GET ${p}`); + } + }); +}); + +describe("WebSocket routes", () => { + test("upgrades need the token and our own Origin", async () => { + const tryWs = (url: string, origin?: string) => + new Promise((resolve) => { + const ws = new WebSocket(url, origin ? ({ headers: { origin } } as never) : undefined); + ws.onopen = () => { + ws.close(); + resolve("open"); + }; + ws.onerror = () => resolve("refused"); + setTimeout(() => resolve("timeout"), 3000); + }); + const ws = `ws://127.0.0.1:${port}`; + expect(await tryWs(`${ws}/ws/events`)).toBe("refused"); + expect(await tryWs(`${ws}/ws/events?t=${app.openToken}`)).toBe("refused"); + expect(await tryWs(`${ws}/ws/events?t=${token}`, "https://evil.example")).toBe("refused"); + expect(await tryWs(`${ws}/ws/events?t=${token}`, "null")).toBe("refused"); + expect(await tryWs(`${ws}/ws/events?t=${token}`, `http://127.0.0.1:${port}`)).toBe("open"); + expect(await tryWs(`${ws}/ws/pty?id=x&t=${token}`, `http://127.0.0.1:${port}`)).toBe("refused"); // id too short + }); +}); + +describe("the app shell", () => { + test("is public but can never be framed, and runs only its own script", async () => { + for (const p of ["/", "/index.html", "/app.js", "/app.css", "/favicon.svg"]) { + const r = await fetch(`${base}${p}`); + expect([p, r.status]).toEqual([p, 200]); + expect(r.headers.get("x-frame-options")).toBe("DENY"); + const csp = r.headers.get("content-security-policy") ?? ""; + expect(csp).toContain("frame-ancestors 'none'"); + expect(csp).toContain("script-src 'self';"); + expect(csp).not.toMatch(/unsafe-eval|script-src[^;]*unsafe-inline|\*/); + await r.arrayBuffer(); + } + }); +}); diff --git a/test/imagecheck.test.ts b/test/imagecheck.test.ts new file mode 100644 index 0000000..f57cf5b --- /dev/null +++ b/test/imagecheck.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test"; +import { imageRefusal, imageSize, MAX_SVG_USES, svgUseExpansion } from "../src/client/imagecheck.ts"; + +const png = (w: number, h: number) => { + const b = new Uint8Array(33); + b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]); + new DataView(b.buffer).setUint32(16, w); + new DataView(b.buffer).setUint32(20, h); + return b; +}; + +test("image sizes are read from the header of each format", () => { + expect(imageSize(png(640, 480))).toEqual({ w: 640, h: 480 }); + expect(imageSize(new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x20, 0x03, 0x58, 0x02]))).toEqual({ w: 800, h: 600 }); + const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 4, 0, 0, 0xff, 0xc0, 0, 17, 8, 0x01, 0xe0, 0x02, 0x80, 3, 0, 0, 0, 0, 0, 0, 0, 0]); + expect(imageSize(jpeg)).toEqual({ w: 640, h: 480 }); + const webp = new Uint8Array(30); + webp.set([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x58]); + webp.set([0x0f, 0x27, 0x00, 0x0f, 0x27, 0x00], 24); // 9999+1 each + expect(imageSize(webp)).toEqual({ w: 10000, h: 10000 }); + expect(imageSize(new Uint8Array([1, 2, 3]))).toBeNull(); +}); + +test("a decompression bomb is refused, an ordinary image is not", () => { + expect(imageRefusal(png(20000, 20000), "image/png")).toMatch(/too large/); + expect(imageRefusal(png(4000, 3000), "image/png")).toBeNull(); +}); + +test("SVG bombs are refused, ordinary reuse is not", () => { + // 6 levels of 10 references: a million elements from 1.6 KB. + let svg = ''; + for (let lvl = 1; lvl <= 6; lvl++) svg += `${``.repeat(10)}`; + svg += ''; + expect(svgUseExpansion(svg)).toBeGreaterThan(MAX_SVG_USES); + expect(imageRefusal(new TextEncoder().encode(svg), "image/svg+xml")).toMatch(/too many/); + // A cycle is refused too. + expect(svgUseExpansion('')).toBeGreaterThan(MAX_SVG_USES); + // Icons reusing a symbol a few dozen times are fine. + const icons = `${''.repeat(50)}`; + expect(svgUseExpansion(icons)).toBe(50); + expect(imageRefusal(new TextEncoder().encode(icons), "image/svg+xml")).toBeNull(); +}); diff --git a/test/index.coverage.test.ts b/test/index.coverage.test.ts index 6078556..510be06 100644 --- a/test/index.coverage.test.ts +++ b/test/index.coverage.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile, mkdir, realpath, chmod } from "node:fs/promises"; +import { mkdtemp, rm, writeFile, mkdir, realpath, chmod, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { FileIndex, HEAVY_DIRS } from "../src/server/index.ts"; @@ -165,3 +165,37 @@ test("symlinked directories inside the root are walked; links out of the root an await rm(outside, { recursive: true, force: true }); } }); + +test("apply(): a new symlink to a file outside the root is not listed (same rule as build())", async () => { + const outside = await realpath(await mkdtemp(path.join(tmpdir(), "cv-index-out-"))); + try { + await writeFile(path.join(outside, "secret.txt"), ""); + const ix = new FileIndex(root); + await ix.get(); + await symlink(path.join(outside, "secret.txt"), path.join(root, "out.txt")); + await symlink(path.join(root, "top.txt"), path.join(root, "in.txt")); + await ix.apply(["out.txt", "in.txt"]); + const files = await list(ix); + expect(files).not.toContain("out.txt"); + expect(files).toContain("in.txt"); + } finally { + await rm(outside, { recursive: true, force: true }); + } +}); + +test("apply(): deleting many files is not quadratic (each one used to rescan the whole set)", async () => { + const many = path.join(root, "many"); + await mkdir(many); + const names = Array.from({ length: 3000 }, (_, i) => `many/f${i}.txt`); + await Promise.all(names.map((n) => writeFile(path.join(root, n), ""))); + const ix = new FileIndex(root); + await ix.get(); + await rm(many, { recursive: true }); + const gone = names.slice(0, 1999); + const t0 = performance.now(); + expect(await ix.apply(gone)).toBe(true); + expect(performance.now() - t0).toBeLessThan(1000); + // The deleted directory itself drops everything below it. + await ix.apply(["many"]); + expect((await list(ix)).some((f) => f.startsWith("many/"))).toBe(false); +}); diff --git a/test/invariants.static.test.ts b/test/invariants.static.test.ts new file mode 100644 index 0000000..7647f89 --- /dev/null +++ b/test/invariants.static.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; + +// The rules in AGENTS.md ("Rules that are easy to break"), checked against the source so that a +// future change breaking one fails here with the rule's name, not in a review months later. + +const ROOT = path.join(import.meta.dir, ".."); +const read = (rel: string) => readFileSync(path.join(ROOT, rel), "utf8"); +function files(dir: string, ext = /\.ts$/): string[] { + const out: string[] = []; + for (const name of readdirSync(path.join(ROOT, dir))) { + const rel = `${dir}/${name}`; + if (statSync(path.join(ROOT, rel)).isDirectory()) out.push(...files(rel, ext)); + else if (ext.test(name)) out.push(rel); + } + return out; +} +/** Source without comments, so a rule is checked against code, not prose about it. */ +const code = (rel: string) => + read(rel) + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:"'`\\])\/\/.*$/gm, "$1"); +const SERVER = files("src/server"); +const CLIENT = files("src/client"); + +describe("No dependencies", () => { + test("package.json declares no packages of any kind", () => { + const pkg = JSON.parse(read("package.json")) as Record; + for (const k of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies", "bundledDependencies"]) { + expect(pkg[k]).toBeUndefined(); + } + }); + + test("no import names a package (only relative paths, node: and bun builtins)", () => { + for (const f of [...SERVER, ...CLIENT, ...files("scripts")]) { + for (const m of read(f).matchAll(/^\s*import\s[^;]*?from\s+["']([^"']+)["']/gm)) { + const spec = m[1]!; + const ok = spec.startsWith(".") || spec.startsWith("node:") || spec === "bun" || spec.startsWith("bun:"); + if (!ok) throw new Error(`${f} imports "${spec}"`); + } + } + }); + + test("the page loads nothing from another origin", () => { + const html = read("src/client/index.html"); + expect(html).not.toMatch(/(src|href)\s*=\s*["']?(https?:)?\/\//i); + }); +}); + +describe("Privacy: the only outbound request is the update check", () => { + test("server code opens no sockets of its own", () => { + for (const f of SERVER) { + const c = code(f); + for (const api of [/\bnet\.connect\(/, /\bBun\.connect\(/, /\bhttps?\.request\(/, /\bnew WebSocket\(/, /\bdgram\b/, /\bBun\.udpSocket\(/]) { + if (api.test(c)) throw new Error(`${f} uses ${api}`); + } + } + }); + + test("fetch() is called only by the updater and by `codeviewer open` (loopback)", () => { + const callers = SERVER.filter((f) => /\bfetch\(/.test(code(f).replace(/async fetch\(req/g, ""))); + expect(callers.sort()).toEqual(["src/server/main.ts", "src/server/update.ts"]); + // `codeviewer open` talks only to the URL its own terminal was given. + expect(code("src/server/main.ts")).toMatch(/fetch\(`\$\{url\}\/api\/open`/); + }); + + test("the updater only talks to GitHub", () => { + const u = read("src/server/update.ts"); + expect(u).toMatch(/https:\/\/api\.github\.com\/repos\/\$\{REPO\}\/releases\/latest/); + const hosts = /export const ASSET_HOSTS = \[([^\]]*)\]/.exec(u)![1]!; + for (const h of hosts.match(/"[^"]+"/g)!) expect(h).toMatch(/^"([a-z-]+\.)*(github\.com|githubusercontent\.com)"$/); + }); + + test("client requests are same-origin (relative URLs only)", () => { + for (const f of CLIENT) { + for (const m of code(f).matchAll(/\bfetch\(\s*(["'`])([^"'`]*)/g)) { + if (!m[2]!.startsWith("/") && !m[2]!.startsWith("${")) throw new Error(`${f}: fetch(${m[1]}${m[2]}`); + } + if (/new WebSocket\((?!wsUrl\()/.test(code(f))) throw new Error(`${f} opens a WebSocket without wsUrl()`); + } + }); +}); + +describe("Git hardening", () => { + test("git is spawned only by git.ts, and every run carries HARDEN", () => { + for (const f of SERVER.filter((f) => f !== "src/server/git.ts")) { + expect(code(f)).not.toMatch(/\[\s*["']git["']|which\(["']git["']\)/); + } + const g = code("src/server/git.ts"); + const runs = [...g.matchAll(/\brun\((?:this\.)?bin,[^\n]*/g)].map((m) => m[0]); + expect(runs.length).toBeGreaterThan(0); + for (const r of runs) expect(r).toContain("...HARDEN"); + }); + + test("HARDEN holds only literal `-c` pairs (overrides from data go through GIT_CONFIG_KEY_n)", () => { + const g = read("src/server/git.ts"); + const block = /const HARDEN = \[([\s\S]*?)\n\];/.exec(g)![1]!.replace(/\/\/.*$/gm, ""); + const items = block.match(/"[^"]*"|`[^`]*`|[A-Za-z_$][\w$.]*/g)!; + for (let i = 0; i < items.length; i += 2) { + expect(items[i]).toBe('"-c"'); + expect(items[i + 1]).toMatch(/^"[a-zA-Z0-9.]+=[^"]*"$/); // a literal key=value, no interpolation + } + }); + + test("the run environment forbids transports and lazy fetches, and never prompts or pages", () => { + const g = read("src/server/git.ts"); + const env = /const ENV = \{([\s\S]*?)\n\};/.exec(g)![1]!; + for (const kv of ['GIT_ALLOW_PROTOCOL: ""', 'GIT_NO_LAZY_FETCH: "1"', 'GIT_OPTIONAL_LOCKS: "0"', 'GIT_TERMINAL_PROMPT: "0"', 'GIT_PAGER: "cat"']) { + expect(env).toContain(kv); + } + const harden = /const HARDEN = \[([\s\S]*?)\n\];/.exec(g)![1]!; + for (const k of [ + "core.fsmonitor=false", "diff.external=", "diff.ignoreSubmodules=all", "core.attributesFile=/dev/null", "log.showSignature=false", + "diff.autoRefreshIndex=false", "core.hooksPath=/dev/null", + ]) { + expect(harden).toContain(`"${k}"`); + } + }); +}); + +describe("Auth", () => { + test("tokens are compared only with tokenOk (constant time)", () => { + const h = code("src/server/http.ts"); + expect(h).not.toMatch(/[=!]==?\s*(opts\.token|openToken)\b|\b(opts\.token|openToken)\s*[=!]==?/); + expect(h).toMatch(/timingSafeEqual/); + }); + + test("the server never logs (no request data, path or token can reach a log)", () => { + for (const f of ["src/server/http.ts", "src/server/files.ts", "src/server/search.ts", "src/server/search.worker.ts", "src/server/git.ts", "src/server/pty.ts", "src/server/index.ts", "src/server/watch.ts", "src/server/settings.ts"]) { + expect(code(f)).not.toMatch(/\bconsole\.(log|error|warn|info|debug)\(/); + } + }); + + test("Bun's development error page stays off", () => { + const h = code("src/server/http.ts"); + expect(h).toMatch(/development:\s*false/); + expect(h).toMatch(/\berror\(\)\s*\{\s*return fail\(500/); + }); +}); + +describe("Read-only and containment", () => { + test("the HTTP layer reads files only through files.ts (resolveSafe + openRegular)", () => { + const h = code("src/server/http.ts"); + expect(h).not.toMatch(/\breadFile\(|\bcreateReadStream\(|\bopenSync\(|from "node:fs"/); + const bunFiles = [...h.matchAll(/Bun\.file\(([^)]*)\)/g)].map((m) => m[1]); + expect(bunFiles).toEqual(["FONTS[p]!"]); // the embedded fonts, never a request path + }); + + test("openRegular keeps O_NOFOLLOW and O_NONBLOCK, and the search worker opens with both too", () => { + expect(code("src/server/files.ts")).toMatch(/O_RDONLY \| constants\.O_NOFOLLOW \| constants\.O_NONBLOCK/); + expect(code("src/server/search.worker.ts")).toMatch(/O_RDONLY \| constants\.O_NOFOLLOW \| constants\.O_NONBLOCK/); + }); + + test("no server code writes outside the settings file and the updater's staging dir", () => { + const writers = /\b(writeFile|appendFile|rename|rm|unlink|mkdir|chmod|copyFile|symlink|truncate)(Sync)?\(|Bun\.write\(|\.writer\(\)/; + const allowed = new Set(["src/server/settings.ts", "src/server/update.ts", "src/server/browser.ts"]); + for (const f of SERVER) if (!allowed.has(f) && writers.test(code(f))) throw new Error(`${f} writes to the file system`); + }); +}); + +describe("Untrusted content", () => { + test("Markdown is rendered without innerHTML", () => { + for (const f of ["src/client/markdown.ts", "src/client/mdrender.ts", "src/client/mdimage.ts"]) { + expect(code(f)).not.toMatch(/\.(innerHTML|outerHTML)\s*=|insertAdjacentHTML\(|document\.write\(/); + } + }); + + test("the app shell CSP is exactly as strict as reviewed (it must not widen)", () => { + const h = read("src/server/http.ts"); + const csp = /`default-src 'self'; script-src 'self';[^`]*`\s*\+\s*\n?\s*`[^`]*`/.exec(h)![0].replace(/`\s*\+\s*`/, ""); + expect(csp).toBe( + "`default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self'; " + + "connect-src 'self' ws://${new URL(req.url).host}; object-src 'none'; frame-ancestors 'none'; base-uri 'none'`", + ); + // Raw files: no script even when an SVG or HTML file is opened directly. + expect(h).toContain(`"default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"`); + }); + + test("innerHTML in the client is only ever built from escaped values or fixed markup", () => { + // Each `innerHTML = …${x}…` interpolation must be esc()/safe()/a known-safe helper or a number. + const SAFE = /^(esc|safe|safeText|markBidi|showHidden|icons\.|fileIcon|folderIcon|highlight|plural|spansHtml|labelOf|this\.lineHtml|renderRow|row\(|chevron|[a-z]+Html|String\(|Number\(|\d|i\b|n\b|count|cls|idx|ms\b)/; + const bad: string[] = []; + for (const f of CLIENT) { + for (const m of code(f).matchAll(/\.innerHTML\s*=\s*(`[^`]*`)/g)) { + for (const e of m[1]!.matchAll(/\$\{\s*([^}]*)\}/g)) { + const expr = e[1]!.trim(); + const numeric = /(\.length|\.toLocaleString\(\))$|^Math\./.test(expr); + if (!SAFE.test(expr) && !numeric && !/^[^`]*\?\s*["'`][^"'`<]*["'`]\s*:\s*["'`]/.test(expr)) bad.push(`${f}: \${${expr}}`); + } + } + } + // Reviewed exceptions: numbers from the server, and a message assembled from safe() pieces. + // A new entry here needs the same review: is every byte of it escaped or a number? + const REVIEWED = new Set([ + "src/client/app.ts: ${g.stash}", // Number(...) on the server + "src/client/app.ts: ${s.diff.added}", // counts + "src/client/app.ts: ${s.diff.removed}", + "src/client/changes.ts: ${msg}", // built from safe(this.vs) / safe(d.base.name) + ]); + expect(bad.filter((b) => !REVIEWED.has(b))).toEqual([]); + }); +}); + +describe("Process boundaries", () => { + test("the compiled binary never autoloads bunfig.toml or .env from its working directory (the viewed folder)", () => { + const b = code("scripts/build.ts"); + const compile = /async function compile\([\s\S]*?\n\}/.exec(b)![0]; + for (const f of ["--no-compile-autoload-bunfig", "--no-compile-autoload-dotenv"]) expect(compile).toContain(`"${f}"`); + // And the Mac app starts the server outside the viewed folder. + expect(code("packaging/macos/main.swift")).toMatch(/process\.currentDirectoryURL = URL\(fileURLWithPath: "\/"\)/); + }); + + test("the app window writes nothing to disk and cannot reach the network", async () => { + const { appWindowArgs } = await import("../src/server/browser.ts"); + const a = appWindowArgs("file:///x.html", "/tmp/p"); + for (const f of ["--incognito", "--proxy-server=127.0.0.1:9", "--webrtc-ip-handling-policy=disable_non_proxied_udp", "--disable-extensions"]) expect(a).toContain(f); + expect(a.some((x) => x.startsWith("--host-resolver-rules=MAP * ~NOTFOUND"))).toBe(true); + }); + + test("`codeviewer open` sends the open-only token only to a codeviewer on this machine", async () => { + const src = code("src/server/main.ts"); + expect(src).toMatch(/redirect: "error"/); + expect(src).toMatch(/if \(!isLocalViewerUrl\(url\)\)/); + }); +}); + +describe("Source hygiene", () => { + test("no source or test file holds raw bidi controls (write them as \\u escapes)", () => { + const raw = /[\u202a-\u202e\u2066-\u2069]/; + for (const f of [...SERVER, ...CLIENT, ...files("test"), ...files("scripts")]) if (raw.test(read(f))) throw new Error(`${f} contains a raw bidi control`); + }); +}); + diff --git a/test/markdown.fuzz.test.ts b/test/markdown.fuzz.test.ts index efa40fd..fdee395 100644 --- a/test/markdown.fuzz.test.ts +++ b/test/markdown.fuzz.test.ts @@ -30,7 +30,7 @@ const TOKENS = [ "javascript:", "JaVaScRiPt:", "java\tscript:", "vbscript:", "data:text/html,x", "file:///etc/passwd", "blob:x", "//evil.test/x", "https://a.example/b.png", "http://a.b/c", "mailto:a@b.co", "", "img/a.png", "../a.png", "../../a.png", ".env", "/api/raw?path=.env", "x.md#sec", "#frag", "#", "|", "|---|:-:|", "---", "===", "- ", "* ", "1. ", "- [x] ", "- [ ] ", "> ", - "\n", "\n\n", " ", " ", "\t", "\\", "\u0001", "‮", "​", "name=", "id=", "style=", "onclick=", "on", "\"", "'", "alt", "title", + "\n", "\n\n", " ", " ", "\t", "\\", "\u0001", "\u202e", "​", "name=", "id=", "style=", "onclick=", "on", "\"", "'", "alt", "title", ]; function gen(rnd: () => number): string { diff --git a/test/markdown.limits.test.ts b/test/markdown.limits.test.ts index bfceef3..fc56081 100644 --- a/test/markdown.limits.test.ts +++ b/test/markdown.limits.test.ts @@ -13,10 +13,15 @@ beforeAll(async () => { afterAll(() => uninstallDom()); const run = (src: string) => R.renderMarkdown(M.parseMarkdown(src)); -/** Best of three, so one scheduler hiccup cannot fail a bound. */ +/** + * Best of five, each after a full collection: one scheduler hiccup cannot fail a bound, and neither + * can a collection of garbage other test files left in this process (all run in one; the ratio + * test below failed once on a macOS runner that way, at 3.0x for a parser that grows 2.1x). + */ function time(fn: () => void): number { let best = Infinity; - for (let i = 0; i < 3; i++) { + for (let i = 0; i < 5; i++) { + Bun.gc(true); const t0 = performance.now(); fn(); best = Math.min(best, performance.now() - t0); diff --git a/test/pty.coverage.test.ts b/test/pty.coverage.test.ts index f78176e..ac958d0 100644 --- a/test/pty.coverage.test.ts +++ b/test/pty.coverage.test.ts @@ -86,6 +86,8 @@ test("resize control messages are clamped and applied; bad messages are ignored" m.message(asWs(a), "{not json"); m.message(asWs(a), JSON.stringify({ t: "resize" })); // missing dims: ignored m.message(asWs(a), JSON.stringify({ t: "nonsense" })); + // Valid JSON that is not an object must not throw (a throw in a socket handler exits the server). + for (const v of ["null", "1", '"resize"', "[]", "true"]) expect(() => m.message(asWs(a), v)).not.toThrow(); expect(a.data).toMatchObject({ cols: 100, rows: 30 }); m.message(asWs(a), Buffer.from("stty size\n")); await until(() => a.output().includes("30 100")); @@ -256,6 +258,12 @@ echo FLOODED m.open(asWs(a)); m.message(asWs(a), Buffer.from("sh ./big.sh\n")); await until(() => a.output().includes("FLOODED"), 20000); + // Wait for the shell to go quiet (its next prompt): output after `a` detaches reaches the ring + // but not `a`, and under load that prompt used to arrive just after the close. + for (let last = -1; last !== a.bins.length; ) { + last = a.bins.length; + await Bun.sleep(300); + } const size = Buffer.concat(a.bins).length; expect(size).toBeGreaterThan(450 * 1024); // inside the old "probably trimmed" guess expect(size).toBeLessThan(510 * 1024); diff --git a/test/pty.modes.test.ts b/test/pty.modes.test.ts new file mode 100644 index 0000000..dc5867d --- /dev/null +++ b/test/pty.modes.test.ts @@ -0,0 +1,102 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { ModeTracker, modesPrefix, PtyManager, type PtySocketData } from "../src/server/pty.ts"; + +test("ModeTracker: C1 CSI (UTF-8), colon sub-parameters and the alt-screen group", () => { + const t = new ModeTracker(); + t.write(new TextEncoder().encode("\u009b?1049h\u009b?1000h")); + expect(t.modes.get(1049)).toBe(true); + expect(t.modes.get(1000)).toBe(true); + t.text("\x1b[?2004:1h\x1b[?47h"); + expect(t.modes.get(2004)).toBe(true); + expect(t.modes.has(1049)).toBe(false); // one alt-screen mode at a time + // A bare 0x9B byte is a UTF-8 continuation byte (ě is C4 9B), never CSI. + const n = new ModeTracker(); + n.write(new TextEncoder().encode("ě?1000h")); + expect(n.modes.size).toBe(0); + n.text("\x1b[?25l"); + expect(modesPrefix(n.modes)).toBe("\x1b[?25l"); // a hidden cursor is restored too +}); + +let dir = ""; +let m: PtyManager | null = null; +afterEach(async () => { + m?.killAll(); + m = null; + if (dir) await rm(dir, { recursive: true, force: true }); +}); + +function fakeWs(id: string) { + return { + data: { kind: "pty", id, cols: 80, rows: 24 } as PtySocketData, + texts: [] as string[], + bins: [] as Uint8Array[], + send(s: string) { this.texts.push(s); }, + sendBinary(b: Uint8Array) { this.bins.push(b); }, + close() {}, + getBufferedAmount() { return 0; }, + output() { return new TextDecoder().decode(Buffer.concat(this.bins)); }, + }; +} +const asWs = (w: ReturnType) => w as unknown as Parameters[0]; + +async function until(cond: () => boolean, ms = 10000) { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > ms) throw new Error("timeout"); + await Bun.sleep(20); + } +} + +test("a mode sequence longer than the old 24-byte carry, split across reads, is tracked", async () => { + dir = await realpath(await mkdtemp(path.join(tmpdir(), "cv-ptymodes-"))); + m = new PtyManager({ shell: "/bin/sh", cwd: dir, env: { PATH: process.env.PATH ?? "/usr/bin:/bin", PS1: "$ " } }); + const a = fakeWs("modes-split"); + m.open(asWs(a)); + m.message(asWs(a), Buffer.from("printf '\\033[?1000;1002;1003;1004;10'; sleep 0.3; printf '06;2004h'; echo; echo MARK\n")); + // "\r\nMARK": the program's output, not the echo of the typed command line. + await until(() => a.output().includes("\r\nMARK\r\n")); + m.close(asWs(a)); + type S = { modes: Map; trackModes(d: Uint8Array): void }; + const s = (m as unknown as { sessions: Map }).sessions.get("modes-split")!; + expect(s.modes.get(2004)).toBe(true); + expect(s.modes.get(1006)).toBe(true); + // Deterministically split, 27 bytes before the cut (the old carry kept 24). + s.modes.clear(); + s.trackModes(new TextEncoder().encode("x\x1b[?1000;1002;1003;1004;10")); + s.trackModes(new TextEncoder().encode("06;2004h")); + expect(s.modes.get(2004)).toBe(true); + expect(s.modes.get(1003)).toBe(true); +}); + +test("a trimmed replay includes mode changes in the bytes it skips (a TUI that exited)", async () => { + dir = await realpath(await mkdtemp(path.join(tmpdir(), "cv-ptymodes-"))); + m = new PtyManager({ shell: "/bin/sh", cwd: dir, env: { PATH: process.env.PATH ?? "/usr/bin:/bin", PS1: "$ " } }); + const a = fakeWs("modes-trim"); + m.open(asWs(a)); + m.close(asWs(a)); + const s = (m as unknown as { sessions: Map> }).sessions.get("modes-trim")!; + // The ring's first chunk: it starts inside an old line, then the TUI leaves alt screen, mouse and paste. + const before = new ModeTracker(); + before.text("\x1b[?1049h\x1b[?1000h\x1b[?2004h"); + s.chunks = [{ data: new TextEncoder().encode("partial\x1b[?2004l\x1b[?1000l\x1b[?1049lEXITED\n$ prompt"), state: before.state() }]; + s.ringTrimmed = true; + const b = fakeWs("x"); + (s.sendReplay as (ws: unknown) => void).call(s, b); + const out = b.output(); + expect(out.startsWith("$ prompt")).toBe(true); // no stale mode prefix in front of the prompt + expect(out).not.toContain("\x1b[?1049h"); +}); + +test("a shell that cannot start ends that terminal, not the server", async () => { + dir = await realpath(await mkdtemp(path.join(tmpdir(), "cv-ptymodes-"))); + m = new PtyManager({ shell: "/bin/sh", cwd: path.join(dir, "deleted"), env: { PATH: process.env.PATH ?? "/usr/bin:/bin" } }); + const a = fakeWs("no-shell"); + let closed: unknown[] | null = null; + (a as unknown as { close: (...x: unknown[]) => void }).close = (...x: unknown[]) => (closed = x); + expect(() => m!.open(asWs(a))).not.toThrow(); + expect(a.texts).toEqual([JSON.stringify({ t: "exit", code: -1 })]); + expect(closed).toEqual([1011, "could not start a shell"]); +}); diff --git a/test/pty.tracker.diff.test.ts b/test/pty.tracker.diff.test.ts new file mode 100644 index 0000000..6e23804 --- /dev/null +++ b/test/pty.tracker.diff.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "bun:test"; +import { ModeTracker, modesPrefix } from "../src/server/pty.ts"; +import { Terminal } from "../src/client/term/terminal.ts"; + +// The server rebuilds input-affecting modes on reload from its own tracker (pty.ts). If it ever +// disagrees with the terminal in the page, a reload turns bracketed paste off (a paste then runs +// line by line) or mouse reporting on (clicks type into the shell). Differential fuzz: the same +// random output, split at random points, goes to the real client parser and to the tracker; a +// fresh terminal given the tracker's prefix must end up in the same modes as the live one. + +const PIECES = [ + "\x1b", "[", "?", "\u009b", "\u009d", "\u0090", "\u009c", "\u0098", "\x07", "\x18", "\x1a", "\x1b\\", "\x1b]0;t", "\x1bP", "\x1b_", "\x1b^", + "1049", "1047", "47", "1000", "1002", "1003", "9", "1006", "1004", "2004", "25", "1", "7", "1048", + ";", ":", "h", "l", "h", "l", "!p", "\x1bc", "\x1b(0", "\x1b#8", ">", "=", "$", " ", "\n", "\r", "x", "é", "\x1b[", "\x1b[?", +]; + +function rng(seed: number) { + let s = seed >>> 0 || 1; + return () => ((s = Math.imul(s ^ (s >>> 15), 0x2c1b3c6d) + 0x6d2b79f5) >>> 0) / 2 ** 32; +} + +const view = (t: Terminal) => ({ + paste: t.modes.bracketedPaste, + mouse: t.modes.mouse, + sgr: t.modes.mouseSgr, + focus: t.modes.focusEvents, + appCursor: t.modes.appCursor, + cursor: t.modes.cursorVisible, + alt: t.alt, +}); + +test("the replay prefix restores exactly the modes the live terminal is in (2000 seeded streams)", () => { + const enc = new TextEncoder(); + for (let seed = 1; seed <= 2000; seed++) { + const r = rng(seed); + let text = ""; + const n = 5 + Math.floor(r() * 60); + for (let i = 0; i < n; i++) text += PIECES[Math.floor(r() * PIECES.length)]; + const bytes = enc.encode(text); + const live = new Terminal(80, 24, { reply: () => {} }); + const tracker = new ModeTracker(); + // Split at random byte positions (inside UTF-8 sequences too), as PTY reads do. + for (let at = 0; at < bytes.length; ) { + const len = 1 + Math.floor(r() * 7); + const chunk = bytes.subarray(at, at + len); + live.write(chunk); + tracker.write(chunk); + at += len; + } + const replay = new Terminal(80, 24, { reply: () => {} }); + replay.write(modesPrefix(tracker.modes)); + if (JSON.stringify(view(replay)) !== JSON.stringify(view(live))) { + throw new Error(`seed ${seed}: ${JSON.stringify(text)}\nlive ${JSON.stringify(view(live))}\nreplay ${JSON.stringify(view(replay))}`); + } + } +}); + +test("the reported attack: a C1 CSI after ESC or inside a string does not switch bracketed paste off", () => { + for (const evil of ["\x1b\u009b?2004l", "\x1b]0;\u009b?2004l\x07", "\x1bP\u009b?2004l\x1b\\", "\x1b_\u009b?2004l\x1b\\"]) { + const t = new ModeTracker(); + t.text("\x1b[?2004h" + evil); + expect([evil, t.modes.get(2004)]).toEqual([evil, true]); + } + // While a real C1 CSI in the ground state is honoured, as by the client. + const t = new ModeTracker(); + t.text("\x1b[?2004h\u009b?2004l"); + expect(t.modes.get(2004)).toBe(false); +}); diff --git a/test/redos.matrix.test.ts b/test/redos.matrix.test.ts new file mode 100644 index 0000000..2989073 --- /dev/null +++ b/test/redos.matrix.test.ts @@ -0,0 +1,137 @@ +import { expect, test } from "bun:test"; +import { LANG_IDS, lexLine } from "../src/client/highlight.ts"; +import { headingText, SYMBOL_LANGS, symbols } from "../src/client/symbols.ts"; +import { parseMarkdown, slugify } from "../src/client/markdown.ts"; + +// Untrusted files must never freeze the UI thread. Every highlighter language, every Go to Symbol +// language and the Markdown parser run against pathological input: runs of each special character, +// alternations, long whitespace, and the shapes that were slow before. A new regex that backtracks +// (quadratic or worse) in any of them fails here, including languages added later (LANG_IDS). +// +// Timing without flakiness: candidates are screened at a small size, then the slowest are timed at +// two sizes 8× apart. Linear code grows ~8×, quadratic ~64×; the bound is 24× (and only once the +// large run takes long enough to measure). + +const CHARS = [..." \t/\\*\"'`<>[](){}#-=:;.,$@!%&|^~?+_a0é😀"]; +function inputs(n: number): string[] { + const out: string[] = []; + for (const c of CHARS) { + out.push( + c.repeat(n), + (c + " ").repeat(n / 2), + (c + "a").repeat(n / 2), + "a".repeat(n / 2) + c.repeat(n / 2), + c + " ".repeat(n - 1), + " ".repeat(n - 1) + c, + ("a" + c + c).repeat(n / 3), + ); + } + // Shapes that were super-linear once (DESIGN.md §24). + out.push( + "a".repeat(n / 2) + " /".repeat(n / 4), + "public " + " ".repeat(n), + "[" + " ".repeat(n), + "# a" + " ".repeat(n) + "b", + "http://a" + ")".repeat(n), + "- ".repeat(n / 2) + "x:", + " ".repeat(n / 2) + "x", + "[".repeat(n), + "![a](".repeat(n / 5), + // Multi-character openers that start a scan for their closer (DESIGN.md §24, third pass). + "(/[".repeat(n / 3), + "${".repeat(n / 2), + "$(".repeat(n / 2), + "[(".repeat(n / 2), + "