Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 5 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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/*

Expand Down
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
111 changes: 111 additions & 0 deletions DESIGN.md

Large diffs are not rendered by default.

33 changes: 27 additions & 6 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file:line>` 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

Expand Down
20 changes: 17 additions & 3 deletions packaging/macos/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);
Expand Down
30 changes: 26 additions & 4 deletions src/client/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { imageRefusal } from "./imagecheck.ts";

export interface Entry {
name: string;
type: "dir" | "file" | "link-broken";
Expand Down Expand Up @@ -122,6 +124,16 @@ async function check(res: Response): Promise<Response> {

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<Info> => (await check(await fetch("/api/info"))).json(),
list: async (path: string): Promise<Entry[]> => (await check(await fetch(`/api/list?path=${q(path)}`))).json(),
Expand Down Expand Up @@ -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<ChangesData> => (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;
Expand All @@ -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<string> =>
URL.createObjectURL(await (await check(await fetch(`/api/raw?path=${q(path)}${max ? `&max=${max}` : ""}`))).blob()),
rawBlobUrl: async (path: string, max?: number): Promise<string> => {
const blob = await (await check(await fetch(`/api/raw?path=${q(path)}${max ? `&max=${max}` : ""}`))).blob();
// Decompression and <use> 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<SearchEvent> {
const sp = new URLSearchParams({ q: params.q });
Expand Down
Loading
Loading