From 7b53815e9d51d07bd1150752338c84dc4327ceb1 Mon Sep 17 00:00:00 2001 From: Jun <1059013684@qq.com> Date: Mon, 12 Oct 2026 11:43:12 +0800 Subject: [PATCH] fix: secure web remote and repair Git review and state recovery Reject plaintext business messages and prevent outbound encryption downgrade. Preserve literal Git paths, align rename statistics, and fix SSH tilde quoting. Repair stale pane layouts and workspace selections during state recovery. Add browser, real Git/shell, and isolated Swift recovery regressions to CI. Validation: 42 browser tests, 23 Git checks, and 19 recovery cases passed. Swift/JavaScript syntax and diff checks passed. Full Xcode/UI validation was not run because the local Xcode and Ghostty build dependencies are unavailable. --- .github/workflows/ci.yml | 3 + Glint/Git/GitDiff.swift | 32 +-- Glint/Git/GitService.swift | 8 +- Glint/Resources/WebRemote/web-remote.js | 52 +++- Glint/Workspace/Persistence.swift | 27 +- Glint/Workspace/WorkspaceStore.swift | 53 ++++ GlintTests/PersistenceRecoveryTests.swift | 213 ++++++++++++++- GlintTests/ReviewDiffParsingTests.swift | 48 +++- GlintTests/SSHGitRunnerTests.swift | 27 +- scripts/test-git-review.mjs | 175 ++++++++++++ scripts/test-persistence-recovery.mjs | 123 +++++++++ scripts/test-web-remote-auth.mjs | 315 ++++++++++++++++++++++ 12 files changed, 1043 insertions(+), 33 deletions(-) create mode 100644 scripts/test-git-review.mjs create mode 100644 scripts/test-persistence-recovery.mjs create mode 100644 scripts/test-web-remote-auth.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1441fac4..1e2a68df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,9 @@ jobs: - name: Test Web Remote browser input run: node --test scripts/test-web-remote-*.mjs + - name: Test Git and persistence regressions + run: node --test scripts/test-git-review.mjs scripts/test-persistence-recovery.mjs + - name: Test Glint run: | set -euo pipefail diff --git a/Glint/Git/GitDiff.swift b/Glint/Git/GitDiff.swift index b833f130..b5ca4e10 100644 --- a/Glint/Git/GitDiff.swift +++ b/Glint/Git/GitDiff.swift @@ -29,8 +29,9 @@ struct GitFileChange: Identifiable, Equatable { extension GitService { - /// Files changed under `scope`, sorted by path. Never throws — a non-repo or - /// bad base just yields an empty list (the UI shows "no changes"). + /// Return files changed in `repo` under `scope`, sorted by literal path. + /// Parameters: repository directory and comparison scope. Returns an empty + /// list for a non-repository or invalid base; never throws. func changedFiles(repo: String, scope: DiffScope) async -> [GitFileChange] { switch scope { case .workingTree: @@ -41,9 +42,9 @@ extension GitService { // tier it would SIGTERM, drop to `u.ok == false`, and silently // omit every untracked file from the Review list (worse than // truncation: the entries appear to not exist at all). - async let names = git(["diff", "HEAD", "--name-status"], cwd: repo, + async let names = git(["diff", "HEAD", "--no-renames", "--name-status", "-z"], cwd: repo, allowFailure: true, timeout: .read) - async let nums = git(["diff", "HEAD", "--numstat"], cwd: repo, + async let nums = git(["diff", "HEAD", "--no-renames", "--numstat", "-z"], cwd: repo, allowFailure: true, timeout: .read) async let untrk = git(["ls-files", "--others", "--exclude-standard", "-z"], cwd: repo, allowFailure: true, timeout: .read) @@ -62,9 +63,9 @@ extension GitService { return withMtime(map.values.sorted { $0.path < $1.path }, repo: repo) case .branch(let base): - async let names = git(["diff", "\(base)...HEAD", "--name-status"], cwd: repo, + async let names = git(["diff", "\(base)...HEAD", "--no-renames", "--name-status", "-z"], cwd: repo, allowFailure: true, timeout: .read) - async let nums = git(["diff", "\(base)...HEAD", "--numstat"], cwd: repo, + async let nums = git(["diff", "\(base)...HEAD", "--no-renames", "--numstat", "-z"], cwd: repo, allowFailure: true, timeout: .read) let map = Self.mergeNameNumstat( nameStatus: (try? await names)?.stdout ?? "", @@ -129,15 +130,16 @@ extension GitService { // MARK: parsing - /// Merge `--name-status` (kind per path) with `--numstat` (line counts). No - /// `-M`: a rename appears as a delete + add pair, which both commands agree - /// on, so keying by path is consistent. + /// Merge literal paths from NUL-delimited Git output. Parameters: + /// `nameStatus` from `--name-status -z --no-renames` and `numstat` from + /// `--numstat -z --no-renames`. Returns changes keyed by their actual paths. + /// Explicitly disabling renames keeps delete/add paths aligned even when + /// the repository enables rename detection; NUL avoids Git's quoted paths. static func mergeNameNumstat(nameStatus: String, numstat: String) -> [String: GitFileChange] { var kinds: [String: GitFileChange.Kind] = [:] - for line in nameStatus.split(separator: "\n", omittingEmptySubsequences: true) { - let parts = line.split(separator: "\t") - guard parts.count >= 2 else { continue } - let code = String(parts[0]); let path = String(parts[parts.count - 1]) + var names = nameStatus.split(separator: "\0", omittingEmptySubsequences: true).makeIterator() + while let code = names.next(), let rawPath = names.next() { + let path = String(rawPath) switch code.first { case "A": kinds[path] = .added case "D": kinds[path] = .deleted @@ -146,7 +148,7 @@ extension GitService { } } var out: [String: GitFileChange] = [:] - for line in numstat.split(separator: "\n", omittingEmptySubsequences: true) { + for line in numstat.split(separator: "\0", omittingEmptySubsequences: true) { let parts = line.split(separator: "\t", maxSplits: 2, omittingEmptySubsequences: false) guard parts.count == 3 else { continue } let a = String(parts[0]); let d = String(parts[1]); let path = String(parts[2]) @@ -154,7 +156,7 @@ extension GitService { out[path] = GitFileChange(path: path, kind: kinds[path] ?? .modified, additions: Int(a) ?? 0, deletions: Int(d) ?? 0, isBinary: binary) } - // Mode-only / rename entries can appear in name-status but not numstat. + // Mode-only entries can appear in name-status but not numstat. for (path, kind) in kinds where out[path] == nil { out[path] = GitFileChange(path: path, kind: kind, additions: 0, deletions: 0, isBinary: false) } diff --git a/Glint/Git/GitService.swift b/Glint/Git/GitService.swift index aa3b7c18..257aa2fd 100644 --- a/Glint/Git/GitService.swift +++ b/Glint/Git/GitService.swift @@ -489,14 +489,15 @@ struct SSHGitRunner: GitRunner { } /// Quote `cwd` for inclusion as `git -C ` in the remote command. + /// Parameter: remote working directory. Returns one re-parse-safe shell word. /// Single-quotes everything EXCEPT a leading `~` (or `~user`) segment, /// which is left unquoted so the remote login shell performs tilde /// expansion. Bash's default `\w` PS1 prints `~/proj` for the user's home /// — keeping that working is the whole point of the carve-out. /// /// Examples (assuming remote `$HOME=/home/deploy`): - /// `~/proj` → `~'/proj'` → `/home/deploy/proj` - /// `~admin/proj` → `~admin'/proj'` → admin's `$HOME` + `/proj` + /// `~/proj` → `~/'proj'` → `/home/deploy/proj` + /// `~admin/proj` → `~admin/'proj'` → admin's `$HOME` + `/proj` /// `~` → `~` → `/home/deploy` /// `/abs/path` → `'/abs/path'` → literal /// `/srv/has space` → `'/srv/has space'` → literal (space safe in quotes) @@ -525,7 +526,8 @@ struct SSHGitRunner: GitRunner { } guard safe else { return posixShellQuoted(cwd) } guard let slash else { return tildePart } - return tildePart + posixShellQuoted(String(cwd[slash...])) + // POSIX shells need the first slash unquoted to terminate the tilde prefix. + return tildePart + "/" + posixShellQuoted(String(cwd[cwd.index(after: slash)...])) } } diff --git a/Glint/Resources/WebRemote/web-remote.js b/Glint/Resources/WebRemote/web-remote.js index c0bf1cb5..fab816dc 100644 --- a/Glint/Resources/WebRemote/web-remote.js +++ b/Glint/Resources/WebRemote/web-remote.js @@ -380,6 +380,7 @@ function websocketURL() { return `${scheme}//${location.hostname}:${httpPort + 1}/control`; } +/** Open a fresh WebSocket session; takes no arguments and returns nothing. */ function connect() { clearTimeout(reconnectTimer); if (socket) { @@ -395,6 +396,7 @@ function connect() { const currentSocket = socket; socket.binaryType = "arraybuffer"; socket.addEventListener("open", () => { + if (socket !== currentSocket) return; lastServerMessageAt = Date.now(); reconnectDelay = 500; // The server issues an auth-challenge as soon as the socket opens; we wait @@ -406,7 +408,7 @@ function connect() { lastServerMessageAt = Date.now(); const data = event.data; if (typeof data === "string") { - handleMessage(data); // plaintext: auth-challenge / handshake error + handlePlaintextFrame(data); } else { onEncryptedFrame(data); // binary: encrypted frame } @@ -420,7 +422,36 @@ function connect() { reconnectTimer = setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 1.8, 8000); }); - socket.addEventListener("error", () => setStatus("error", t("unable_connect"))); + socket.addEventListener("error", () => { + if (socket !== currentSocket) return; + setStatus("error", t("unable_connect")); + }); +} + +/** Validate a plaintext handshake frame; takes raw JSON and returns nothing. */ +function handlePlaintextFrame(raw) { + if (encrypted || authenticated) { + socket?.close(); + return; + } + let message; + try { + message = JSON.parse(raw); + } catch { + socket?.close(); + return; + } + const validChallenge = message?.type === "auth-challenge" + && pendingChallenge === null + && typeof message.challenge === "string" + && /^[A-Za-z0-9+/]{43}=$/.test(message.challenge); + const validError = message?.type === "error" + && ["unauthorized", "bad-request"].includes(message.code); + if (!validChallenge && !validError) { + socket?.close(); + return; + } + handleMessage(raw); } function reconnectIfStale() { @@ -465,8 +496,9 @@ function deriveSession(accessKey, challengeB64) { return encodeBase64(proof); } -/// Send the proof once both the access key and the challenge are known. +/** Send the handshake proof when ready; takes no arguments and returns nothing. */ function tryAuthenticate() { + if (encrypted || authenticated) return; if (!token || !pendingChallenge) return; if (!socket || socket.readyState !== WebSocket.OPEN) return; const proof = deriveSession(token, pendingChallenge); @@ -511,16 +543,19 @@ function onEncryptedFrame(buffer) { handleMessage(json); } +/** Send a JSON command without plaintext fallback; takes a message and returns nothing. */ function send(message) { if (!socket || socket.readyState !== WebSocket.OPEN) return; const json = JSON.stringify(message); if (encrypted && c2sKey) { socket.send(encryptFrame(json)); - } else { + } else if (!encrypted && !authenticated && message.type === "authenticate" + && pendingChallenge && c2sKey && s2cKey) { socket.send(json); } } +/** Dispatch validated protocol JSON; takes a JSON string and returns nothing. */ function handleMessage(raw) { let message; try { @@ -530,6 +565,10 @@ function handleMessage(raw) { } switch (message.type) { case "auth-challenge": + if (encrypted) { + socket?.close(); + return; + } pendingChallenge = message.challenge; tryAuthenticate(); break; @@ -755,8 +794,13 @@ function applyTheme(theme) { }; } +/** Update protocol error UI; takes a server error code and returns nothing. */ function handleError(code) { if (code === "unauthorized") { + if (encrypted) { + socket?.close(); + return; + } authenticated = false; updateFilesButton(); token = ""; diff --git a/Glint/Workspace/Persistence.swift b/Glint/Workspace/Persistence.swift index e88579da..042bcc13 100644 --- a/Glint/Workspace/Persistence.swift +++ b/Glint/Workspace/Persistence.swift @@ -150,7 +150,8 @@ enum Persistence { /// `Pane`. Returns re-serialized JSON if a bad pane was removed, nil if /// nothing changed or the structure is unrecognizable (so the caller only /// retries when there's something to retry with). Lets one bad pane cost - /// only that pane instead of the whole file. + /// only that pane instead of the whole file. Parameter data is saved JSON; + /// the returned repair also removes stale layout/focus references. static func stripBadPanes(from data: Data) -> Data? { guard var root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], var workspaces = root["workspaces"] as? [[String: Any]] else { return nil } @@ -173,7 +174,17 @@ enum Persistence { } j += 2 } - if kept.count != panes.count { workspaces[i]["panes"] = kept } + if kept.count != panes.count { + workspaces[i]["panes"] = kept + // Workspace's decoder repairs the split trees after pruning; + // persist those repaired references along with the registry. + if let blob = SafeJSON.data(workspaces[i]), + let workspace = try? pdecoder.decode(Workspace.self, from: blob), + let encoded = try? JSONEncoder().encode(workspace), + let repaired = (try? JSONSerialization.jsonObject(with: encoded)) as? [String: Any] { + workspaces[i] = repaired + } + } } guard changed else { return nil } root["workspaces"] = workspaces @@ -186,25 +197,33 @@ enum Persistence { /// letting one bad workspace cost only that workspace instead of the whole /// file. Returns nil if nothing was dropped, if every workspace is bad /// (abstaining lets the caller fall back to a fresh state and preserve the - /// original on disk), or if the structure is unrecognizable. + /// original on disk), or if the structure is unrecognizable. Parameter data + /// is saved JSON; a returned repair selects an existing active survivor. static func stripBadWorkspaces(from data: Data) -> Data? { guard var root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], let workspaces = root["workspaces"] as? [Any] else { return nil } let wdecoder = JSONDecoder() var kept: [Any] = [] + var decoded: [Workspace] = [] var dropped = 0 for ws in workspaces { // SafeJSON, not bare data(withJSONObject:): a stray non-JSON leaf // or non-finite number throws an NSException (uncatchable by try?). let blob = SafeJSON.data(ws) ?? Data() - if (try? wdecoder.decode(Workspace.self, from: blob)) != nil { + if let workspace = try? wdecoder.decode(Workspace.self, from: blob) { kept.append(ws) + decoded.append(workspace) } else { dropped += 1 } } guard dropped > 0, !kept.isEmpty else { return nil } root["workspaces"] = kept + if let selected = root["selectedWorkspaceID"] as? String, + let id = UUID(uuidString: selected), + !decoded.contains(where: { $0.id == id }) { + root["selectedWorkspaceID"] = (decoded.first(where: { !$0.archived }) ?? decoded[0]).id.uuidString + } NSLog("[glint] \(fileName): dropping \(dropped) undecodable workspace(s), keeping \(kept.count)") return SafeJSON.data(root) } diff --git a/Glint/Workspace/WorkspaceStore.swift b/Glint/Workspace/WorkspaceStore.swift index e6c09ebd..f1506dd1 100644 --- a/Glint/Workspace/WorkspaceStore.swift +++ b/Glint/Workspace/WorkspaceStore.swift @@ -116,6 +116,25 @@ extension SplitNode { case .split(_, _, let a, let b): return a.leaves + b.leaves } } + + /// Prune missing panes; validIDs contains registered pane IDs; returns the surviving tree or nil. + func retainingPanes(_ validIDs: Set) -> SplitNode? { + switch self { + case .leaf(let id): + return validIDs.contains(id) ? self : nil + case .split(let direction, let ratio, let a, let b): + let keptA = a.retainingPanes(validIDs) + let keptB = b.retainingPanes(validIDs) + switch (keptA, keptB) { + case (.some(let a), .some(let b)): + return .split(direction: direction, ratio: ratio, a: a, b: b) + case (.some(let node), .none), (.none, .some(let node)): + return node + case (.none, .none): + return nil + } + } + } } /// Where a workspace's shell/agent/git actually run, and what repo/branch it's @@ -344,6 +363,7 @@ struct Workspace: Identifiable, Codable { self.source = source } + /// Decode a workspace and repair dangling pane references; decoder supplies saved data; returns a restored workspace or throws. init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) self.id = try c.decode(UUID.self, forKey: .id) @@ -386,6 +406,33 @@ struct Workspace: Identifiable, Codable { self.selectedTabID = TabID(value: 0) self.nextTabSeq = 1 } + + let validIDs = Set(panes.keys) + for index in self.tabs.indices { + if let root = self.tabs[index].root.retainingPanes(validIDs) { + self.tabs[index].root = root + if !root.leaves.contains(self.tabs[index].focusedPane) { + self.tabs[index].focusedPane = root.leaves[0] + } + } else { + // An entirely damaged tab still needs a registered terminal; + // preserve its identity/name instead of restoring a blank tab. + let maxID = panes.keys.map(\.value).max() + guard self.nextPaneSeq < UInt32.max, + maxID.map({ $0 < UInt32.max - 1 }) ?? true else { + throw DecodingError.dataCorruptedError(forKey: .nextPaneSeq, in: c, + debugDescription: "Pane identifier sequence is exhausted") + } + if let maxID { + self.nextPaneSeq = Swift.max(self.nextPaneSeq, maxID + 1) + } + let id = PaneID(value: self.nextPaneSeq) + self.nextPaneSeq += 1 + self.panes[id] = Pane(id: id, title: "zsh") + self.tabs[index].root = .leaf(id) + self.tabs[index].focusedPane = id + } + } } // Explicit because CodingKeys carries the legacy `root`/`focusedPane` @@ -433,6 +480,7 @@ struct PersistedState: Codable { case version, workspaces, selectedWorkspaceID, sidebarCollapsed } + /// Decode saved state; decoder supplies the snapshot; returns state with a valid workspace selection or throws. init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) // Files written before versioning have no `version` key — they are @@ -441,6 +489,11 @@ struct PersistedState: Codable { self.workspaces = try c.decode([Workspace].self, forKey: .workspaces) self.selectedWorkspaceID = try c.decodeIfPresent(UUID.self, forKey: .selectedWorkspaceID) self.sidebarCollapsed = try c.decodeIfPresent(Bool.self, forKey: .sidebarCollapsed) ?? false + if let selectedWorkspaceID, + !workspaces.contains(where: { $0.id == selectedWorkspaceID }) { + self.selectedWorkspaceID = workspaces.first(where: { !$0.archived })?.id + ?? workspaces.first?.id + } } static var fresh: PersistedState { diff --git a/GlintTests/PersistenceRecoveryTests.swift b/GlintTests/PersistenceRecoveryTests.swift index 243f091b..d4d39fc4 100644 --- a/GlintTests/PersistenceRecoveryTests.swift +++ b/GlintTests/PersistenceRecoveryTests.swift @@ -9,13 +9,222 @@ import XCTest /// guarantee. final class PersistenceRecoveryTests: XCTestCase { - private func stateData(workspaces: [Workspace]) throws -> Data { + /// Encode a fixture without disk access; workspaces and selection are inputs; returns JSON data. + private func stateData(workspaces: [Workspace], selectedWorkspaceID: UUID? = nil) throws -> Data { let state = PersistedState(workspaces: workspaces, - selectedWorkspaceID: nil, + selectedWorkspaceID: selectedWorkspaceID, sidebarCollapsed: false) return try JSONEncoder().encode(state) } + /// Corrupt pane values in a fixture; workspace, pane IDs and legacy flag are inputs; returns broken JSON. + private func corruptPanes(in workspace: Workspace, ids: Set, legacy: Bool = false) throws -> Data { + var root = try XCTUnwrap(JSONSerialization.jsonObject(with: stateData(workspaces: [workspace])) as? [String: Any]) + var encoded = try XCTUnwrap((root["workspaces"] as? [[String: Any]])?.first) + var panes = try XCTUnwrap(encoded["panes"] as? [Any]) + for index in stride(from: 0, to: panes.count, by: 2) { + let keyData = try JSONSerialization.data(withJSONObject: panes[index]) + if ids.contains(try JSONDecoder().decode(PaneID.self, from: keyData)) { + panes[index + 1] = ["__corrupted": true] + } + } + encoded["panes"] = panes + if legacy { + let tab = try XCTUnwrap((encoded["tabs"] as? [[String: Any]])?.first) + encoded["root"] = tab["root"] + encoded["focusedPane"] = tab["focusedPane"] + encoded.removeValue(forKey: "tabs") + } + root["workspaces"] = [encoded] + return try JSONSerialization.data(withJSONObject: root) + } + + /// Build a split fixture; no parameters; returns a workspace with two registered panes. + private func splitWorkspace() -> Workspace { + var workspace = Workspace.fresh(name: "Split", accentHex: "5E5CE6", symbol: "S") + let other = PaneID(value: 1) + workspace.panes[other] = Pane(id: other, title: "bash", workingDirectory: "/tmp") + workspace.tabs[0].root = .split(direction: .horizontal, ratio: 0.4, + a: .leaf(PaneID(value: 0)), b: .leaf(other)) + workspace.nextPaneSeq = 2 + return workspace + } + + /// Recover a broken focused leaf; no parameters; returns no value and asserts the surviving pane is shown. + func testStripBadPanesPrunesSplitAndRepairsFocus() throws { + let workspace = splitWorkspace() + let broken = try corruptPanes(in: workspace, ids: [PaneID(value: 0)]) + let repaired = try XCTUnwrap(Persistence.stripBadPanes(from: broken)) + let recovered = try XCTUnwrap(JSONDecoder().decode(PersistedState.self, from: repaired).workspaces.first) + XCTAssertEqual(recovered.tabs[0].root.leaves, [PaneID(value: 1)]) + XCTAssertEqual(recovered.tabs[0].focusedPane, PaneID(value: 1)) + XCTAssertEqual(recovered.panes[PaneID(value: 1)]?.workingDirectory, "/tmp") + let serialized = try XCTUnwrap(JSONSerialization.jsonObject(with: repaired) as? [String: Any]) + let saved = try XCTUnwrap((serialized["workspaces"] as? [[String: Any]])?.first) + let savedTab = try XCTUnwrap((saved["tabs"] as? [[String: Any]])?.first) + let savedTree = try XCTUnwrap(savedTab["root"] as? [String: Any]) + XCTAssertEqual(savedTree["kind"] as? String, "leaf", "the saved repair must remove stale split references") + } + + /// Recover a pre-tabs split; no parameters; returns no value and asserts migration has no stale leaves. + func testStripBadPanesRepairsLegacySplit() throws { + let workspace = splitWorkspace() + let broken = try corruptPanes(in: workspace, ids: [PaneID(value: 0)], legacy: true) + let repaired = try XCTUnwrap(Persistence.stripBadPanes(from: broken)) + let recovered = try XCTUnwrap(JSONDecoder().decode(PersistedState.self, from: repaired).workspaces.first) + XCTAssertEqual(recovered.tabs[0].root.leaves, [PaneID(value: 1)]) + XCTAssertEqual(recovered.tabs[0].focusedPane, PaneID(value: 1)) + } + + /// Recover an entirely broken tab; no parameters; returns no value and asserts a usable fresh terminal remains. + func testStripBadPanesReplacesEntirelyBrokenTab() throws { + let workspace = splitWorkspace() + let broken = try corruptPanes(in: workspace, ids: Set(workspace.panes.keys)) + let repaired = try XCTUnwrap(Persistence.stripBadPanes(from: broken)) + let recovered = try XCTUnwrap(JSONDecoder().decode(PersistedState.self, from: repaired).workspaces.first) + XCTAssertEqual(recovered.id, workspace.id) + XCTAssertEqual(recovered.tabs[0].id, workspace.tabs[0].id) + XCTAssertEqual(recovered.panes.count, 1) + let leaf = try XCTUnwrap(recovered.tabs[0].root.leaves.first) + XCTAssertNotNil(recovered.panes[leaf]) + XCTAssertEqual(recovered.tabs[0].focusedPane, leaf) + XCTAssertGreaterThan(recovered.nextPaneSeq, leaf.value) + } + + /// Recover a workspace's only broken terminal; no parameters; returns no value and asserts its replacement is visible. + @MainActor + func testStripBadPanesReplacesOnlyPaneWithVisibleTerminal() throws { + let workspace = Workspace.fresh(name: "Only", accentHex: "5E5CE6", symbol: "O") + let broken = try corruptPanes(in: workspace, ids: Set(workspace.panes.keys)) + let repaired = try XCTUnwrap(Persistence.stripBadPanes(from: broken)) + let recovered = try XCTUnwrap(JSONDecoder().decode(PersistedState.self, from: repaired).workspaces.first) + let pane = recovered.tabs[0].focusedPane + XCTAssertEqual(recovered.panes.count, 1) + XCTAssertNotNil(recovered.panes[pane]) + XCTAssertTrue(WorkspaceStore.paneIsVisible(.init(workspace: recovered.id, pane: pane), + selectedWorkspaceID: recovered.id, in: recovered)) + } + + /// Repair a focus pointing into another tab; no parameters; returns no value and asserts focus stays inside its tree. + func testDecodeRepairsFocusThatPointsToAnotherTab() throws { + var workspace = splitWorkspace() + workspace.tabs[0].root = .leaf(PaneID(value: 0)) + workspace.tabs[0].focusedPane = PaneID(value: 1) + workspace.tabs.append(WorkspaceTab(id: TabID(value: 1), name: nil, + root: .leaf(PaneID(value: 1)), focusedPane: PaneID(value: 1))) + workspace.nextTabSeq = 2 + let recovered = try XCTUnwrap(JSONDecoder().decode(PersistedState.self, from: stateData(workspaces: [workspace])).workspaces.first) + XCTAssertEqual(recovered.tabs[0].focusedPane, PaneID(value: 0)) + XCTAssertEqual(recovered.tabs[1].focusedPane, PaneID(value: 1)) + } + + /// Reject exhausted IDs during empty-tab recovery; no parameters; returns no value and asserts a decoding error instead of a crash. + func testDecodeRejectsExhaustedNextPaneSequence() throws { + var workspace = Workspace.fresh(name: "Exhausted", accentHex: "5E5CE6", symbol: "E") + workspace.panes = [:] + workspace.nextPaneSeq = UInt32.max + var decodeError: Error? + do { + _ = try JSONDecoder().decode(PersistedState.self, from: stateData(workspaces: [workspace])) + } catch { + decodeError = error + } + XCTAssertTrue(decodeError is DecodingError) + } + + /// Reject a registry whose maximum ID cannot advance; no parameters; returns no value and asserts recovery delegates to quarantine. + func testDecodeRejectsExhaustedRegisteredPaneID() throws { + for value in [UInt32.max, UInt32.max - 1] { + var workspace = Workspace.fresh(name: "Exhausted", accentHex: "5E5CE6", symbol: "E") + let id = PaneID(value: value) + workspace.panes = [id: Pane(id: id, title: "zsh")] + var decodeError: Error? + do { + _ = try JSONDecoder().decode(PersistedState.self, from: stateData(workspaces: [workspace])) + } catch { + decodeError = error + } + XCTAssertTrue(decodeError is DecodingError) + } + } + + /// Recover one broken tab among several; no parameters; returns no value and asserts healthy tabs remain intact. + func testStripBadPanesKeepsHealthyTabAndSelection() throws { + var workspace = splitWorkspace() + workspace.tabs[0].root = .leaf(PaneID(value: 0)) + let second = WorkspaceTab(id: TabID(value: 1), name: "Healthy", root: .leaf(PaneID(value: 1)), focusedPane: PaneID(value: 1)) + workspace.tabs.append(second) + workspace.selectedTabID = second.id + workspace.nextTabSeq = 2 + let broken = try corruptPanes(in: workspace, ids: [PaneID(value: 0)]) + let repaired = try XCTUnwrap(Persistence.stripBadPanes(from: broken)) + let recovered = try XCTUnwrap(JSONDecoder().decode(PersistedState.self, from: repaired).workspaces.first) + XCTAssertEqual(recovered.tabs.count, 2) + XCTAssertEqual(recovered.selectedTabID, second.id) + XCTAssertEqual(recovered.tabs[1].name, "Healthy") + XCTAssertEqual(recovered.tabs[1].root.leaves, [PaneID(value: 1)]) + XCTAssertEqual(recovered.tabs[1].focusedPane, PaneID(value: 1)) + XCTAssertNotNil(recovered.panes[recovered.tabs[0].focusedPane]) + } + + /// Leave healthy state untouched; no parameters; returns no value and asserts no repair rewrite is requested. + func testStripBadPanesHealthyDataReturnsNil() throws { + let workspace = splitWorkspace() + let clean = try stateData(workspaces: [workspace]) + XCTAssertNil(Persistence.stripBadPanes(from: clean)) + let recovered = try XCTUnwrap(JSONDecoder().decode(PersistedState.self, from: clean).workspaces.first) + XCTAssertEqual(recovered.tabs[0].root.leaves, workspace.tabs[0].root.leaves) + XCTAssertEqual(recovered.tabs[0].focusedPane, workspace.tabs[0].focusedPane) + XCTAssertEqual(recovered.nextPaneSeq, workspace.nextPaneSeq) + } + + /// Repair the selection of a removed workspace; no parameters; returns no value and asserts an active survivor is selected. + func testStripBadWorkspacesRepairsRemovedSelection() throws { + let doomed = Workspace.fresh(name: "Doomed", accentHex: "5E5CE6", symbol: "D") + var archived = Workspace.fresh(name: "Archived", accentHex: "5E5CE6", symbol: "A") + archived.archived = true + let healthy = Workspace.fresh(name: "Healthy", accentHex: "5E5CE6", symbol: "H") + var root = try XCTUnwrap(JSONSerialization.jsonObject(with: stateData(workspaces: [doomed, archived, healthy], selectedWorkspaceID: doomed.id)) as? [String: Any]) + var workspaces = try XCTUnwrap(root["workspaces"] as? [Any]) + workspaces[0] = ["__corrupted": true] + root["workspaces"] = workspaces + let repaired = try XCTUnwrap(Persistence.stripBadWorkspaces(from: JSONSerialization.data(withJSONObject: root))) + let recovered = try JSONDecoder().decode(PersistedState.self, from: repaired) + XCTAssertEqual(recovered.selectedWorkspaceID, healthy.id) + let saved = try XCTUnwrap(JSONSerialization.jsonObject(with: repaired) as? [String: Any]) + XCTAssertEqual(saved["selectedWorkspaceID"] as? String, healthy.id.uuidString) + } + + /// Decode a stale UUID without workspace corruption; no parameters; returns no value and asserts selection falls back. + func testDecodeRepairsDanglingWorkspaceSelection() throws { + let healthy = Workspace.fresh(name: "Healthy", accentHex: "5E5CE6", symbol: "H") + let decoded = try JSONDecoder().decode(PersistedState.self, from: stateData(workspaces: [healthy], selectedWorkspaceID: UUID())) + XCTAssertEqual(decoded.selectedWorkspaceID, healthy.id) + } + + /// Preserve a valid non-first selection; no parameters; returns no value and asserts the user's choice survives decoding. + func testDecodePreservesHealthyWorkspaceSelection() throws { + let first = Workspace.fresh(name: "First", accentHex: "5E5CE6", symbol: "F") + let selected = Workspace.fresh(name: "Selected", accentHex: "5E5CE6", symbol: "S") + let decoded = try JSONDecoder().decode(PersistedState.self, from: stateData(workspaces: [first, selected], selectedWorkspaceID: selected.id)) + XCTAssertEqual(decoded.selectedWorkspaceID, selected.id) + } + + /// Preserve absent selection for launch policy; no parameters; returns no value and asserts nil remains nil. + func testDecodePreservesAbsentWorkspaceSelection() throws { + let workspace = Workspace.fresh(name: "First", accentHex: "5E5CE6", symbol: "F") + let decoded = try JSONDecoder().decode(PersistedState.self, from: stateData(workspaces: [workspace])) + XCTAssertNil(decoded.selectedWorkspaceID) + } + + /// Recover selection with only archived survivors; no parameters; returns no value and asserts the first survivor is used. + func testDecodeDanglingSelectionFallsBackWhenAllArchived() throws { + var workspace = Workspace.fresh(name: "Archived", accentHex: "5E5CE6", symbol: "A") + workspace.archived = true + let decoded = try JSONDecoder().decode(PersistedState.self, from: stateData(workspaces: [workspace], selectedWorkspaceID: UUID())) + XCTAssertEqual(decoded.selectedWorkspaceID, workspace.id) + } + /// Two good workspaces, then one is replaced with a dict missing every /// required Workspace field. stripBadWorkspaces must drop only the bad one /// and leave the survivor decodable. diff --git a/GlintTests/ReviewDiffParsingTests.swift b/GlintTests/ReviewDiffParsingTests.swift index edb6fcfd..0c4f182e 100644 --- a/GlintTests/ReviewDiffParsingTests.swift +++ b/GlintTests/ReviewDiffParsingTests.swift @@ -12,21 +12,65 @@ import XCTest final class ReviewDiffParsingTests: XCTestCase { private struct NonLocalDiffRunner: GitRunner { + /// Return NUL-delimited fixture output. Parameters: Git args, cwd and timeout. Returns a successful result. func run(_ args: [String], cwd: String?, timeout: GitTimeout) async throws -> GitResult { if args.contains("--name-status") { - return GitResult(exitCode: 0, stdout: "M\tfile.txt\n", stderr: "") + return GitResult(exitCode: 0, stdout: "M\0file.txt\0", stderr: "") } if args.contains("--numstat") { - return GitResult(exitCode: 0, stdout: "1\t1\tfile.txt\n", stderr: "") + return GitResult(exitCode: 0, stdout: "1\t1\tfile.txt\0", stderr: "") } return GitResult(exitCode: 0, stdout: "", stderr: "") } + /// Supply empty untracked counts. Parameters: repository and paths. Returns an empty map. func countUntrackedAdditions(repo: String, paths: [String]) async -> [String: Int] { [:] } } + /// Preserve literal path bytes in NUL records. Parameters: none. Returns nothing; asserts counts and kinds. + func testMergeNulRecordsPreservesSpecialFilenames() { + let paths = ["中文.txt", "tab\tfile.txt", "line\nfile.txt", "quote\"file.txt"] + let names = paths.map { "M\0" + $0 + "\0" }.joined() + let nums = paths.map { "2\t1\t" + $0 + "\0" }.joined() + + let changes = GitService.mergeNameNumstat(nameStatus: names, numstat: nums) + + XCTAssertEqual(Set(changes.keys), Set(paths)) + for path in paths { + XCTAssertEqual(changes[path]?.kind, .modified) + XCTAssertEqual(changes[path]?.additions, 2) + XCTAssertEqual(changes[path]?.deletions, 1) + } + } + + /// Keep binary and mode-only entries when merging. Parameters: none. Returns nothing; asserts metadata. + func testMergeNulRecordsKeepsBinaryAndModeOnlyChanges() { + let changes = GitService.mergeNameNumstat( + nameStatus: "M\0binary.dat\0M\0mode.txt\0", + numstat: "-\t-\tbinary.dat\0") + + XCTAssertEqual(changes.count, 2) + XCTAssertEqual(changes["binary.dat"]?.isBinary, true) + XCTAssertEqual(changes["binary.dat"]?.additions, 0) + XCTAssertEqual(changes["mode.txt"]?.kind, .modified) + XCTAssertEqual(changes["mode.txt"]?.deletions, 0) + } + + /// Merge rename-as-delete/add records by their actual paths. Parameters: none. Returns nothing; asserts counts. + func testMergeNulRecordsKeepsRenameAsDeleteAndAdd() { + let changes = GitService.mergeNameNumstat( + nameStatus: "D\0old.txt\0A\0new.txt\0", + numstat: "0\t3\told.txt\03\t0\tnew.txt\0") + + XCTAssertEqual(Set(changes.keys), ["old.txt", "new.txt"]) + XCTAssertEqual(changes["old.txt"]?.kind, .deleted) + XCTAssertEqual(changes["old.txt"]?.deletions, 3) + XCTAssertEqual(changes["new.txt"]?.kind, .added) + XCTAssertEqual(changes["new.txt"]?.additions, 3) + } + private func file(_ path: String) -> GitFileChange { GitFileChange(path: path, kind: .modified, additions: 1, deletions: 1, isBinary: false) } diff --git a/GlintTests/SSHGitRunnerTests.swift b/GlintTests/SSHGitRunnerTests.swift index 4aaaf965..ebfa7ac5 100644 --- a/GlintTests/SSHGitRunnerTests.swift +++ b/GlintTests/SSHGitRunnerTests.swift @@ -84,22 +84,24 @@ final class SSHGitRunnerTests: XCTestCase { /// performs tilde expansion against the user's actual `$HOME` — otherwise /// `~/proj` would land at a literal directory called `~`. Everything after /// the tilde segment is single-quoted so spaces/metachars are still safe. + /// Keep the slash outside quotes for POSIX tilde expansion. Parameters: none. Returns nothing; checks wire argv. func testCommandArgsPreservesLeadingTildeUnquoted() { let a = SSHGitRunner.commandArgs(target: "h", port: nil, controlPath: "/tmp/x.sock", cwd: "~/code/api", gitArgs: ["status"]) let gitIndex = a.firstIndex(of: "git")! XCTAssertEqual(a[gitIndex + 1], "-C") - XCTAssertEqual(a[gitIndex + 2], "~'/code/api'") + XCTAssertEqual(a[gitIndex + 2], "~/'code/api'") } /// `~admin/proj` must expand to admin's home (not the login user's), so the /// whole `~admin` prefix stays unquoted as one shell tilde-prefix word. + /// Keep the named-user tilde prefix unquoted. Parameters: none. Returns nothing; checks wire argv. func testTildeUserPreserved() { let a = SSHGitRunner.commandArgs(target: "h", port: nil, controlPath: "/x", cwd: "~admin/proj", gitArgs: []) let gitIndex = a.firstIndex(of: "git")! - XCTAssertEqual(a[gitIndex + 2], "~admin'/proj'") + XCTAssertEqual(a[gitIndex + 2], "~admin/'proj'") } /// Bare `~` (no slash) means `$HOME` — keep it unquoted with nothing @@ -142,11 +144,30 @@ final class SSHGitRunnerTests: XCTestCase { /// shell tilde-expands them; previously `.` wasn't in the carve-out /// allowlist, so the path silently fell through to literal quoting and /// every dotted-user Review showed "Not a git repository". + /// Preserve a dotted username before the unquoted slash. Parameters: none. Returns nothing; checks wire argv. func testTildeDottedUsernamePreserved() { let a = SSHGitRunner.commandArgs(target: "h", port: nil, controlPath: "/x", cwd: "~admin.user/proj", gitArgs: []) let gitIndex = a.firstIndex(of: "git")! - XCTAssertEqual(a[gitIndex + 2], "~admin.user'/proj'") + XCTAssertEqual(a[gitIndex + 2], "~admin.user/'proj'") + } + + /// Execute shell quoting in supported login shells. Parameters: none. Returns nothing; asserts expansion and injection safety. + func testTildeExpansionAndSpecialCharactersInLoginShells() throws { + let suffix = "has space/a'b;$(printf injected)`printf bad`" + let cwd = "~/" + suffix + let expected = ProcessInfo.processInfo.environment["HOME"]! + "/" + suffix + for shell in ["/bin/bash", "/bin/sh", "/bin/zsh"] { + let process = Process(), output = Pipe() + process.executableURL = URL(fileURLWithPath: shell) + process.arguments = ["-c", "printf '%s' " + SSHGitRunner.quoteRemoteCwd(cwd)] + process.standardOutput = output + try process.run() + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + XCTAssertEqual(process.terminationStatus, 0, shell) + XCTAssertEqual(String(decoding: data, as: UTF8.self), expected, shell) + } } } diff --git a/scripts/test-git-review.mjs b/scripts/test-git-review.mjs new file mode 100644 index 00000000..f9050b66 --- /dev/null +++ b/scripts/test-git-review.mjs @@ -0,0 +1,175 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const scratch = mkdtempSync(join(tmpdir(), 'glint-git-review-')); +let failures = 0; +let passed = 0; + +/** Run an executable. Parameters: executable, argv, options. Returns stdout or throws on failure. */ +function run(executable, args, options = {}) { + const result = spawnSync(executable, args, { encoding: 'utf8', ...options }); + assert.equal(result.status, 0, `${executable}: ${result.stderr || result.error}`); + return result.stdout; +} + +/** Extract one production Swift function. Parameters: source and signature. Returns its complete body. */ +function extractFunction(source, signature) { + const start = source.indexOf(signature); + assert.notEqual(start, -1, `Missing production function: ${signature}`); + const open = source.indexOf('{', start); + let depth = 1; + let end = open + 1; + for (; depth && end < source.length; end++) { + if (source[end] === '{') depth++; + if (source[end] === '}') depth--; + } + assert.equal(depth, 0); + return source.slice(start, end); +} + +/** Record a behavioral check without hiding other failures. Parameters: name and body. Returns nothing. */ +function check(name, body) { + try { body(); passed++; console.log(`PASS ${name}`); } + catch (error) { failures++; console.error(`FAIL ${name}: ${error.message}`); } +} + +try { + const service = readFileSync(join(root, 'Glint/Git/GitService.swift'), 'utf8'); + const diff = readFileSync(join(root, 'Glint/Git/GitDiff.swift'), 'utf8'); + // Compile the complete production diff extension; only the process runner and + // unrelated application types are stand-ins, so command flags are exercised. + const support = String.raw` +import Foundation +enum GitTimeout { case read } +struct GitResult { let stdout: String; let ok: Bool; let wasSignaled = false } +protocol GitRunner: Sendable { + func run(_ args: [String], cwd: String?) throws -> GitResult + func countUntrackedAdditions(repo: String, paths: [String]) async -> [String: Int] +} +struct LocalGitRunner: GitRunner { + /// Execute real Git. Parameters: argv and cwd. Returns decoded output and success. + func run(_ args: [String], cwd: String?) throws -> GitResult { + let process = Process(), output = Pipe(), errors = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = args + process.currentDirectoryURL = cwd.map { URL(fileURLWithPath: $0) } + process.standardOutput = output; process.standardError = errors + try process.run() + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return GitResult(stdout: String(decoding: data, as: UTF8.self), ok: process.terminationStatus == 0) + } + /// Stub unused untracked counts. Parameters: repo and paths. Returns an empty map. + func countUntrackedAdditions(repo: String, paths: [String]) async -> [String: Int] { [:] } +} +struct GitService { + let runner: any GitRunner = LocalGitRunner() + /// Forward production diff calls to Git. Parameters: argv, cwd, failure policy, timeout. Returns output. + func git(_ args: [String], cwd: String?, allowFailure: Bool, timeout: GitTimeout) async throws -> GitResult { + try runner.run(args, cwd: cwd) + } +} +`; + const entry = String.raw` +struct Row: Encodable { let path: String; let kind: String; let additions: Int; let deletions: Int; let binary: Bool; let diff: String } +@main struct Regression { + /// Drive production diff/quote functions. Parameters: command-line args. Returns JSON or shell token to stdout. + static func main() async throws { + let args = CommandLine.arguments + if args[1] == "quote" { print(SSHGitRunner.quoteRemoteCwd(args[2])); return } + if args[1] == "parse" { + let paths = ["中文.txt", "tab\tfile.txt", "line\nfile.txt", "quote\"file.txt"] + let names = paths.map { "M\0" + $0 + "\0" }.joined() + "D\0mode.txt\0" + let nums = paths.map { "2\t1\t" + $0 + "\0" }.joined() + "-\t-\tbinary.dat\0" + let files = GitService.mergeNameNumstat(nameStatus: names, numstat: nums) + let rows = files.values.sorted { $0.path < $1.path }.map { Row(path: $0.path, kind: $0.kind.rawValue, additions: $0.additions, deletions: $0.deletions, binary: $0.isBinary, diff: "") } + print(String(decoding: try JSONEncoder().encode(rows), as: UTF8.self)); return + } + let scope: DiffScope = args[1] == "working" ? .workingTree : .branch(base: args[3]) + let git = GitService() + var rows: [Row] = [] + for file in await git.changedFiles(repo: args[2], scope: scope) { + rows.append(Row(path: file.path, kind: file.kind.rawValue, additions: file.additions, deletions: file.deletions, + binary: file.isBinary, diff: await git.fileDiff(repo: args[2], scope: scope, file: file))) + } + print(String(decoding: try JSONEncoder().encode(rows), as: UTF8.self)) + } +} +`; + const swiftPath = join(scratch, 'Regression.swift'); + const executable = join(scratch, 'regression'); + writeFileSync(swiftPath, support + diff + '\n' + extractFunction(service, 'func posixShellQuoted(') + + '\nenum SSHGitRunner {\n' + extractFunction(service, 'static func quoteRemoteCwd(') + '\n}\n' + entry); + run('/usr/bin/swiftc', ['-parse-as-library', '-module-cache-path', join(scratch, 'cache'), swiftPath, '-o', executable]); + + const paths = ['中文.txt', 'tab\tfile.txt', 'line\nfile.txt', 'quote"file.txt']; + check('NUL parsing preserves names, mode-only changes and binary counts', () => { + const rows = JSON.parse(run(executable, ['parse'])); + assert.deepEqual(rows.map(row => row.path).sort(), [...paths, 'mode.txt', 'binary.dat'].sort()); + for (const path of paths) assert.deepEqual(rows.find(row => row.path === path), + { path, kind: 'modified', additions: 2, deletions: 1, binary: false, diff: '' }); + assert.equal(rows.find(row => row.path === 'mode.txt').kind, 'deleted'); + assert.equal(rows.find(row => row.path === 'binary.dat').binary, true); + }); + + const repo = join(scratch, 'repo'); + run('/usr/bin/git', ['init', '-q', repo]); + run('/usr/bin/git', ['-C', repo, 'config', 'user.name', 'Regression']); + run('/usr/bin/git', ['-C', repo, 'config', 'user.email', 'regression@example.invalid']); + run('/usr/bin/git', ['-C', repo, 'config', 'diff.renames', 'true']); + for (const path of [...paths, 'old.txt']) writeFileSync(join(repo, path), 'old\n'); + run('/usr/bin/git', ['-C', repo, 'add', '.']); + run('/usr/bin/git', ['-C', repo, 'commit', '-qm', 'base']); + const base = run('/usr/bin/git', ['-C', repo, 'rev-parse', 'HEAD']).trim(); + for (const path of paths) writeFileSync(join(repo, path), 'new\n'); + run('/usr/bin/git', ['-C', repo, 'mv', 'old.txt', 'new.txt']); + + /** Validate Review rows against real files. Parameters: scope and arguments. Returns nothing. */ + function verifyScope(scope, args) { + const rows = JSON.parse(run(executable, args)); + check(`${scope}: special filenames retain readable diffs and correct counts`, () => { + for (const path of paths) { + const row = rows.find(row => row.path === path); + assert.ok(row, `Missing literal path ${JSON.stringify(path)}`); + assert.equal(row.additions, 1); assert.equal(row.deletions, 1); + assert.match(row.diff, /\+new/); assert.match(row.diff, /-old/); + } + }); + check(`${scope}: rename yields deletion and addition without phantom paths`, () => { + assert.deepEqual(rows.map(row => row.path).sort(), [...paths, 'old.txt', 'new.txt'].sort()); + const old = rows.find(row => row.path === 'old.txt'), added = rows.find(row => row.path === 'new.txt'); + assert.equal(old.kind, 'deleted'); assert.equal(old.deletions, 1); assert.match(old.diff, /-old/); + assert.equal(added.kind, 'added'); assert.equal(added.additions, 1); assert.match(added.diff, /\+old/); + }); + } + verifyScope('working tree', ['working', repo]); + run('/usr/bin/git', ['-C', repo, 'add', '.']); + run('/usr/bin/git', ['-C', repo, 'commit', '-qm', 'changes']); + verifyScope('branch', ['branch', repo, base]); + + for (const shell of ['/bin/bash', '/bin/sh', '/bin/zsh']) { + for (const path of ['~/code', '~/has space/a\'b;$(printf injected)`printf bad`', '~', '/absolute/a\'b;$(printf bad)', '~bad;rm/code']) { + check(`${shell}: quotes ${JSON.stringify(path)} safely`, () => { + const token = run(executable, ['quote', path]).trimEnd(); + const actual = run(shell, ['-c', `printf '%s' ${token}`]); + const expected = path === '~' ? process.env.HOME : path.startsWith('~/') ? process.env.HOME + path.slice(1) : path; + assert.equal(actual, expected); + }); + } + const username = run('/usr/bin/id', ['-un']).trim(); + check(`${shell}: named user tilde expands before quoted suffix`, () => { + const token = run(executable, ['quote', `~${username}/has space`]).trimEnd(); + const home = run(shell, ['-c', `printf '%s' ~${username}`]); + assert.equal(run(shell, ['-c', `printf '%s' ${token}`]), home + '/has space'); + }); + } + console.log(`Git Review regression: ${passed} passed, ${failures} failed`); + process.exitCode = failures ? 1 : 0; +} finally { + rmSync(scratch, { recursive: true, force: true }); +} diff --git a/scripts/test-persistence-recovery.mjs b/scripts/test-persistence-recovery.mjs new file mode 100644 index 00000000..a6f1d0a1 --- /dev/null +++ b/scripts/test-persistence-recovery.mjs @@ -0,0 +1,123 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +// Compile the actual Codable models, recovery helpers and test bodies with +// Command Line Tools. CLT has no XCTest: a small assertion runner below executes +// the same assertions. UI-only types are placeholders; no Store is initialized, +// no Application Support directory is accessed and no hooks are installed. +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const source = fs.readFileSync(path.join(root, 'Glint/Workspace/WorkspaceStore.swift'), 'utf8'); +const modelStart = source.indexOf('enum SplitDirection:'); +const factoryStart = source.indexOf('\nextension Workspace {', modelStart); +const factoryEnd = source.indexOf(' /// The currently visible tab', factoryStart); +const visibilityStart = source.indexOf(' static func paneIsVisible('); +const visibilityEnd = source.indexOf('\n private static func parsePaneKey', visibilityStart); +const selectedTabStart = source.indexOf(' var selectedTab: WorkspaceTab?'); +const selectedTabEnd = source.indexOf('\n', selectedTabStart); +if ([modelStart, factoryStart, factoryEnd, visibilityStart, visibilityEnd, selectedTabStart, selectedTabEnd].some(index => index < 0)) { + throw new Error('Model extraction markers changed; update the isolated test harness.'); +} +const stubs = `import Foundation +struct Color { + /// Stand in for UI color; hex is a color string; returns a placeholder color. + init?(hex: String) {} + /// Stand in for UI color; parameters are RGB components; returns a placeholder color. + init(red: Double, green: Double, blue: Double) {} +} +enum PaneAgentKind: String { case claude, codex, opencode, devin, omp, grok } +struct WorkspacePaneKey { let workspace: UUID; let pane: PaneID } +`; +const models = stubs + source.slice(modelStart, factoryStart) + + source.slice(factoryStart, factoryEnd) + '\n}\n' + + 'extension Workspace {\n' + source.slice(selectedTabStart, selectedTabEnd) + '\n}\n' + + 'enum WorkspaceStore {\n' + source.slice(visibilityStart, visibilityEnd) + '\n}\n'; +const tests = fs.readFileSync(path.join(root, 'GlintTests/PersistenceRecoveryTests.swift'), 'utf8') + .replace('@testable import Glint', '') + .replace('import XCTest', 'import Foundation') + .replace('XCTestCase', 'RecoveryTestCase') + .replace(/\bXCTUnwrap\b/g, 'unwrap') + .replace(/\bXCTAssertEqual\b/g, 'expectEqual') + .replace(/\bXCTAssertGreaterThan\b/g, 'expectGreaterThan') + .replace(/\bXCTAssertNotNil\b/g, 'expectNotNil') + .replace(/\bXCTAssertNil\b/g, 'expectNil') + .replace(/\bXCTAssertTrue\b/g, 'expectTrue') + .replace(/^ @MainActor\s*$/gm, ''); +const names = [...tests.matchAll(/func (test\w+)\(\) throws/g)].map(match => match[1]); +if (names.length === 0 || /\bXCT(?:Assert\w*|Unwrap)\b/.test(tests)) { + throw new Error('No recovery tests found or unsupported XCTest assertion; update the isolated test harness.'); +} +const assertions = `import Foundation +class RecoveryTestCase {} +struct AssertionFailure: Error { let message: String } +var failures = 0 +/// Record assertion failure; message and source line are inputs; returns no value. +func fail(_ message: String, line: UInt) { + failures += 1 + print("FAIL line \\(line): \\(message)") +} +/// Unwrap an optional test value; value and message are inputs; returns its payload or throws. +func unwrap(_ value: T?, _ message: String = "Expected non-nil") throws -> T { + guard let value else { throw AssertionFailure(message: message) } + return value +} +/// Check equality; two values, message and source line are inputs; returns no value. +func expectEqual(_ a: T, _ b: T, _ message: String = "", line: UInt = #line) { + if a != b { fail("\\(a) != \\(b) \\(message)", line: line) } +} +/// Check ordering; two values, message and source line are inputs; returns no value. +func expectGreaterThan(_ a: T, _ b: T, _ message: String = "", line: UInt = #line) { + if a <= b { fail("\\(a) <= \\(b) \\(message)", line: line) } +} +/// Check absence; value, message and source line are inputs; returns no value. +func expectNil(_ value: T?, _ message: String = "", line: UInt = #line) { + if value != nil { fail("Expected nil \\(message)", line: line) } +} +/// Check presence; value, message and source line are inputs; returns no value. +func expectNotNil(_ value: T?, _ message: String = "", line: UInt = #line) { + if value == nil { fail("Expected non-nil \\(message)", line: line) } +} +/// Check a condition; value, message and source line are inputs; returns no value. +func expectTrue(_ value: Bool, _ message: String = "", line: UInt = #line) { + if !value { fail("Expected true \\(message)", line: line) } +} +`; +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'glint-persistence-regression-')); +try { + fs.writeFileSync(path.join(dir, 'Models.swift'), models); + fs.writeFileSync(path.join(dir, 'Tests.swift'), tests); + fs.writeFileSync(path.join(dir, 'Assertions.swift'), assertions); + fs.writeFileSync(path.join(dir, 'main.swift'), + 'import Foundation\nlet tests: [(String, (PersistenceRecoveryTests) -> () throws -> Void)] = [\n' + + names.map(name => `("${name}", PersistenceRecoveryTests.${name})`).join(',\n') + + `\n]\nlet suite = PersistenceRecoveryTests() +for (name, test) in tests { + let before = failures + do { try test(suite)() } catch { fail(String(describing: error), line: 0) } + print("\\(failures == before ? "PASS" : "FAIL"): \\(name)") +} +print("Executed \\(tests.count) recovery cases; \\(failures) assertion failures") +exit(failures == 0 ? 0 : 1) +`); + const binary = path.join(dir, 'persistence-tests'); + const compile = spawnSync('swiftc', ['-swift-version', '5', '-module-cache-path', path.join(os.tmpdir(), 'glint-recovery-module-cache'), '-o', binary, + path.join(dir, 'Models.swift'), path.join(dir, 'Tests.swift'), path.join(dir, 'Assertions.swift'), path.join(dir, 'main.swift'), + path.join(root, 'Glint/Workspace/Persistence.swift'), path.join(root, 'Glint/Agent/SafeJSON.swift')], + { encoding: 'utf8' }); + process.stdout.write(compile.stdout ?? ''); + process.stderr.write(compile.stderr ?? ''); + if (compile.error) throw compile.error; + if (compile.status !== 0) process.exitCode = compile.status ?? 1; + else { + const run = spawnSync(binary, [], { encoding: 'utf8' }); + process.stdout.write(run.stdout ?? ''); + process.stderr.write(run.stderr ?? ''); + if (run.error) throw run.error; + if (run.signal) process.stderr.write(`Recovery test process terminated by ${run.signal}\n`); + process.exitCode = run.status ?? 1; + } +} finally { + fs.rmSync(dir, { recursive: true, force: true }); +} diff --git a/scripts/test-web-remote-auth.mjs b/scripts/test-web-remote-auth.mjs new file mode 100644 index 00000000..b8316943 --- /dev/null +++ b/scripts/test-web-remote-auth.mjs @@ -0,0 +1,315 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; +import { + hmacSha256, hkdfExtractExpand, hkdfExpand, aesGcmSeal, aesGcmOpen, +} from "../Glint/Resources/WebRemote/crypto.mjs"; + +const source = readFileSync(new URL("../Glint/Resources/WebRemote/web-remote.js", import.meta.url), "utf8"); +const accessKey = "01".repeat(32); +const challenge = Buffer.alloc(32, 7).toString("base64"); +const encoder = new TextEncoder(); + +/** 模拟无副作用的界面操作;无参数;无返回值。 */ +function noop() {} + +/** 提取真实客户端函数;参数为函数名;返回未经修改的函数源码。 */ +function shippedFunction(name) { + const start = source.indexOf(`function ${name}(`); + assert.notEqual(start, -1); + const end = source.indexOf("\n}", start) + 2; + return source.slice(start, end); +} + +/** 创建隔离的 DOM/WebSocket 环境;无参数;返回真实客户端处理器与观察记录。 */ +function harness() { + const sockets = [], writes = [], statuses = [], states = []; + const storage = new Map(); + const elements = { + authDialog: { + open: false, + /** 关闭认证弹窗;无参数;无返回值。 */ + close() { this.open = false; }, + }, + authError: { textContent: "" }, + emptyState: { classList: { add: noop, remove: noop } }, + terminal: { classList: { add: noop, remove: noop } }, + }; + class MockWebSocket { + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + /** 建立测试连接;无参数;返回测试连接实例。 */ + constructor() { + this.readyState = MockWebSocket.OPEN; + this.handlers = new Map(); + this.sent = []; + this.closeCount = 0; + sockets.push(this); + } + /** 注册事件;参数为类型和处理器;无返回值。 */ + addEventListener(type, handler) { this.handlers.set(type, handler); } + /** 记录待发送帧;参数为帧;无返回值。 */ + send(data) { this.sent.push(data); } + /** 标记连接关闭;无参数;无返回值。 */ + close() { this.closeCount += 1; this.readyState = MockWebSocket.CLOSED; } + /** 投递 WebSocket 事件;参数为事件类型和数据;返回处理器结果。 */ + emit(type, data) { return this.handlers.get(type)?.({ data }); } + } + const context = vm.createContext({ + WebSocket: MockWebSocket, TextEncoder, TextDecoder, Uint8Array, DataView, Date, + hmacSha256, hkdfExtractExpand, hkdfExpand, aesGcmSeal, aesGcmOpen, + clearTimeout: noop, setTimeout: noop, updateFilesButton: noop, fitTerminal: noop, + applyBrand: noop, applyTheme: noop, chooseInitialPane: noop, + /** 返回预设访问密钥;无参数;返回十六进制密钥。 */ + loadToken: () => accessKey, + /** 返回测试服务地址;无参数;返回 WebSocket URL。 */ + websocketURL: () => "ws://localhost/control", + /** 记录状态变化;参数为状态和文案;返回记录条数。 */ + setStatus: (...args) => statuses.push(args), + /** 用翻译键代替文案;参数为翻译键;返回该键。 */ + t: key => key, + /** 打开模拟认证弹窗;无参数;无返回值。 */ + showAuth: () => { elements.authDialog.open = true; }, + /** 记录业务状态渲染;参数为状态;返回记录条数。 */ + renderState: state => states.push(state), elements, + sessionStorage: { + /** 读取会话存储;参数为键;返回保存的值。 */ + getItem: key => storage.get(key), + /** 保存会话存储;参数为键和值;返回存储 Map。 */ + setItem: (key, value) => storage.set(key, value), + /** 删除会话存储;参数为键;返回是否存在该键。 */ + removeItem: key => storage.delete(key), + }, + terminal: { + /** 记录终端渲染并完成回调;参数为字节与回调;无返回值。 */ + write: (bytes, callback) => { writes.push(Buffer.from(bytes).toString()); callback?.(); }, + reset: noop, focus: noop, + }, + /** 将字节编码为 base64;参数为字节;返回 base64 文本。 */ + encodeBase64: bytes => Buffer.from(bytes).toString("base64"), + /** 将 base64 解码为字节;参数为文本;返回字节数组。 */ + decodeBase64: value => Uint8Array.from(Buffer.from(value, "base64")), + }); + vm.runInContext(` + const PROOF_LABEL = "glint-webremote/v1/proof", SESSION_INFO = "glint-webremote/v1/session"; + const C2S_INFO = "c2s", S2C_INFO = "s2c"; + const textEncoder = new TextEncoder(), textDecoder = new TextDecoder(); + let autoFocusTerminal = false; + `, context); + vm.runInContext(source.slice(source.indexOf("let socket;"), source.indexOf("const mobileSidebarLayout =")), context); + for (const name of ["hexToBytes", "concatBytes", "nonceFor", "counterFromNonce", "handleError", "errorLabel", "sendInputBytes"]) { + vm.runInContext(shippedFunction(name), context); + } + vm.runInContext(source.slice(source.indexOf("function connect("), source.indexOf("function applyBrand(")), context); + /** 执行真实客户端表达式;参数为源码;返回表达式结果。 */ + function run(code) { return vm.runInContext(code, context); } + /** 投递明文消息;参数为消息及可选连接;返回处理器结果。 */ + function plain(message, connection = sockets.at(-1)) { + return connection.emit("message", typeof message === "string" ? message : JSON.stringify(message)); + } + /** 使用真实协议构造服务器加密帧;参数为消息和计数器;返回二进制帧。 */ + function serverFrame(message, counter = 0) { + const session = hkdfExtractExpand(Buffer.from(accessKey, "hex"), Buffer.from(challenge, "base64"), encoder.encode("glint-webremote/v1/session"), 32); + const key = hkdfExpand(session, encoder.encode("s2c"), 32); + const nonce = new Uint8Array(12); + new DataView(nonce.buffer).setBigUint64(4, BigInt(counter), false); + return Uint8Array.from([...nonce, ...aesGcmSeal(key, nonce, encoder.encode(JSON.stringify(message)))]).buffer; + } + /** 建立并授权加密会话;无参数;无返回值。 */ + function authorize() { + plain({ type: "auth-challenge", challenge }); + sockets.at(-1).emit("message", serverFrame({ type: "authenticated" })); + run('selectedPane = "pane"'); + } + run("connect()"); + return { sockets, writes, statuses, states, elements, run, plain, serverFrame, authorize }; +} + +/** 验证明文输出不能进入已授权终端;无参数;无返回值。 */ +function rejectsPlainOutput() { + const h = harness(); + h.authorize(); + h.plain({ type: "output", pane: "pane", data: Buffer.from("forged").toString("base64") }); + assert.deepEqual(h.writes, []); + assert.equal(h.sockets.at(-1).closeCount, 1); +} +test("encrypted sessions close on plaintext output without rendering it", rejectsPlainOutput); + +/** 验证明文授权不能解锁输入;无参数;无返回值。 */ +function rejectsPlainAuthentication() { + const h = harness(); + h.plain({ type: "authenticated" }); + h.run('selectedPane = "pane"; sendInputBytes(textEncoder.encode("secret"))'); + assert.equal(h.run("authenticated"), false); + assert.deepEqual(h.sockets.at(-1).sent, []); + assert.equal(h.sockets.at(-1).closeCount, 1); +} +test("plaintext authenticated cannot authorize a session or expose input", rejectsPlainAuthentication); + +/** 验证握手 proof 与协议一致;无参数;无返回值。 */ +function acceptsChallengeProof() { + const h = harness(); + h.plain({ type: "auth-challenge", challenge }); + const message = JSON.parse(h.sockets.at(-1).sent[0]); + const expected = hmacSha256(Buffer.from(accessKey, "hex"), Buffer.concat([Buffer.from("glint-webremote/v1/proof"), Buffer.from(challenge, "base64")])); + assert.deepEqual(message, { type: "authenticate", proof: Buffer.from(expected).toString("base64") }); + assert.equal(h.run("authenticated"), false); +} +test("a valid plaintext challenge sends only its HMAC proof", acceptsChallengeProof); + +/** 验证合法密文授权、输出与输入;无参数;无返回值。 */ +function acceptsEncryptedTraffic() { + const h = harness(); + h.authorize(); + const connection = h.sockets.at(-1); + connection.emit("message", h.serverFrame({ type: "output", pane: "pane", data: Buffer.from("real").toString("base64") }, 1)); + connection.emit("message", h.serverFrame({ type: "state", workspaces: [] }, 2)); + connection.emit("message", h.serverFrame({ type: "snapshot", pane: "pane", data: Buffer.from("snapshot").toString("base64") }, 3)); + h.run('sendInputBytes(textEncoder.encode("secret"))'); + assert.equal(h.run("authenticated"), true); + assert.deepEqual(h.writes, ["real", "snapshot"]); + assert.equal(h.states.length, 1); + assert.equal(connection.closeCount, 0); + const frame = connection.sent.at(-1); + assert.ok(frame instanceof Uint8Array); + const message = JSON.parse(new TextDecoder().decode(aesGcmOpen(h.run("c2sKey"), frame.subarray(0, 12), frame.subarray(12)))); + assert.equal(message.type, "input"); + assert.equal(Buffer.from(message.data, "base64").toString(), "secret"); +} +test("verified binary authentication and business messages keep input encrypted", acceptsEncryptedTraffic); + +/** 验证握手错误后可重试相同 challenge;无参数;无返回值。 */ +function retriesUnauthorized() { + const h = harness(); + h.plain({ type: "auth-challenge", challenge }); + h.plain({ type: "error", code: "unauthorized" }); + assert.equal(h.elements.authError.textContent, "unauthorized"); + assert.equal(h.elements.authDialog.open, true); + assert.equal(h.run("authenticated"), false); + assert.equal(h.run("encrypted"), false); + assert.equal(h.run("token"), ""); + h.run(`token = ${JSON.stringify(accessKey)}; tryAuthenticate()`); + assert.equal(h.sockets.at(-1).sent.length, 2); + assert.equal(JSON.parse(h.sockets.at(-1).sent[1]).type, "authenticate"); + h.sockets.at(-1).emit("message", h.serverFrame({ type: "authenticated" })); + assert.equal(h.run("authenticated"), true); +} +test("handshake unauthorized permits a new proof for the original challenge", retriesUnauthorized); + +/** 验证业务发送不降级到明文;无参数;无返回值。 */ +function refusesPlainBusiness() { + for (const phase of ["", "authenticated = true", "encrypted = true; c2sKey = null"]) { + const h = harness(); + h.run(phase); + h.run('send({ type: "input", pane: "pane", data: "c2VjcmV0" }); send({ type: "list" })'); + assert.deepEqual(h.sockets.at(-1).sent, []); + } +} +test("business sends require encryption and a client key", refusesPlainBusiness); + +/** 验证重复 challenge 不重置已推导的会话;无参数;无返回值。 */ +function rejectsRepeatedChallenge() { + const h = harness(); + h.plain({ type: "auth-challenge", challenge }); + h.plain({ type: "auth-challenge", challenge: Buffer.alloc(32, 8).toString("base64") }); + assert.equal(h.sockets.at(-1).sent.length, 1); + assert.equal(h.run("pendingChallenge"), challenge); + assert.equal(h.sockets.at(-1).closeCount, 1); +} +test("a second plaintext challenge cannot replace the handshake challenge", rejectsRepeatedChallenge); + +/** 验证密文会话拒绝所有明文控制消息;无参数;无返回值。 */ +function rejectsPlainControl() { + for (const message of [{ type: "auth-challenge", challenge }, { type: "error", code: "unauthorized" }, { type: "authenticated" }]) { + const h = harness(); + h.authorize(); + h.plain(message); + assert.equal(h.sockets.at(-1).closeCount, 1); + assert.equal(h.run("token"), accessKey); + } +} +test("encrypted sessions reject plaintext challenge, errors and authorization", rejectsPlainControl); + +/** 验证畸形和未知明文帧断开;无参数;无返回值。 */ +function rejectsInvalidPlain() { + for (const message of ["{", "null", "[]", '{"type":"unknown"}', '{"type":"state","workspaces":[]}', '{"type":"auth-challenge"}', '{"type":"auth-challenge","challenge":"bad"}', '{"type":"error"}']) { + const h = harness(); + assert.doesNotThrow(h.plain.bind(null, message)); + assert.equal(h.sockets.at(-1).closeCount, 1, message); + assert.equal(h.states.length, 0); + assert.equal(h.sockets.at(-1).sent.length, 0); + } +} +test("malformed or unexpected plaintext frames close the handshake safely", rejectsInvalidPlain); + +/** 验证过期连接的所有事件不改变新会话;无参数;无返回值。 */ +function ignoresStaleSocket() { + const h = harness(); + const obsolete = h.sockets.at(-1); + h.run("connect()"); + const current = h.sockets.at(-1); + h.run('token = ""; lastServerMessageAt = 123'); + const previousStatusCount = h.statuses.length; + obsolete.emit("open"); + obsolete.emit("error"); + obsolete.emit("close"); + h.plain({ type: "authenticated" }, obsolete); + obsolete.emit("message", h.serverFrame({ type: "authenticated" })); + assert.equal(h.run("lastServerMessageAt"), 123); + assert.equal(h.statuses.length, previousStatusCount); + assert.equal(h.elements.authDialog.open, false); + assert.equal(h.run("authenticated"), false); + assert.equal(current.closeCount, 0); +} +test("stale socket open, error, close and message events do not mutate the current session", ignoresStaleSocket); + +/** 验证未通过验证的二进制帧不能授权;无参数;无返回值。 */ +function rejectsInvalidBinary() { + const h = harness(); + h.plain({ type: "auth-challenge", challenge }); + const frame = new Uint8Array(h.serverFrame({ type: "authenticated" })); + frame[frame.length - 1] ^= 1; + h.sockets.at(-1).emit("message", frame.buffer); + assert.equal(h.run("authenticated"), false); + assert.equal(h.run("encrypted"), false); + assert.equal(h.sockets.at(-1).closeCount, 1); +} +test("tampered binary authentication never establishes a session", rejectsInvalidBinary); + +/** 验证缺密钥时等待人工输入;无参数;无返回值。 */ +function waitsForToken() { + const h = harness(); + h.run('token = ""'); + h.plain({ type: "auth-challenge", challenge }); + assert.equal(h.sockets.at(-1).sent.length, 0); + assert.equal(h.sockets.at(-1).closeCount, 0); + h.run(`token = ${JSON.stringify(accessKey)}; tryAuthenticate()`); + assert.equal(h.sockets.at(-1).sent.length, 1); + assert.equal(JSON.parse(h.sockets.at(-1).sent[0]).type, "authenticate"); +} +test("a challenge can wait for the user to enter an access key", waitsForToken); + +/** 验证合法握手请求错误仍显示错误;无参数;无返回值。 */ +function acceptsHandshakeError() { + const h = harness(); + h.plain({ type: "error", code: "bad-request" }); + assert.deepEqual(h.statuses.at(-1), ["error", "bad_request"]); + assert.equal(h.sockets.at(-1).closeCount, 0); + assert.equal(h.run("authenticated"), false); +} +test("a supported plaintext handshake error remains visible", acceptsHandshakeError); + +/** 验证已建立密文会话不被认证错误降级;无参数;无返回值。 */ +function refusesEncryptedDowngrade() { + const h = harness(); + h.authorize(); + const connection = h.sockets.at(-1); + connection.emit("message", h.serverFrame({ type: "error", code: "unauthorized" }, 1)); + h.run('tryAuthenticate(); send({ type: "list" })'); + assert.equal(connection.closeCount, 1); + assert.equal(h.run("encrypted"), true); + assert.equal(connection.sent.length, 1); +} +test("an encrypted unauthorized error closes without downgrading or resending a proof", refusesEncryptedDowngrade);