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);