diff --git a/src/renderer/src/components/settings/use-settings-repo-scroll-effects.ts b/src/renderer/src/components/settings/use-settings-repo-scroll-effects.ts index 490be7d5c86..4fa0a9c3413 100644 --- a/src/renderer/src/components/settings/use-settings-repo-scroll-effects.ts +++ b/src/renderer/src/components/settings/use-settings-repo-scroll-effects.ts @@ -45,7 +45,7 @@ export function useSettingsRepoScrollEffects( const { neededRepos } = terminal useEffect(() => { - const repoHostIdentitySet = new Set(repos.map(getRepoHostIdentity)) + const repoHostIdentitySet = new Set(repos.map(getRepoHostIdentity)) setRepoHooksMap((previous) => { const next = Object.fromEntries( Object.entries(previous).filter(([identity]) => repoHostIdentitySet.has(identity)) diff --git a/src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts b/src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts index 6002bc8e6eb..f13da1d2710 100644 --- a/src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts +++ b/src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts @@ -111,7 +111,7 @@ export function useWorkspaceSpaceManagerPanel() { dismissDeleteWorktreeFailureToast(target) } void window.api.workspaceSpace.requestWorktreeRefresh?.() - const deletedIdentities = new Set( + const deletedIdentities = new Set( deletedTargets.map((target) => composeWorktreeHostIdentity(target.executionHostId ?? undefined, target.id) ) diff --git a/src/renderer/src/store/slices/repo-host-identity.test.ts b/src/renderer/src/store/slices/repo-host-identity.test.ts new file mode 100644 index 00000000000..1d828e47d2e --- /dev/null +++ b/src/renderer/src/store/slices/repo-host-identity.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import { getRepoHostIdentity, type HostScopedRepoKey } from './repo-host-identity' + +// Why: frozen bytes (D25); a repo's host comes from executionHostId, then connectionId. +describe('getRepoHostIdentity golden bytes', () => { + it('composes the repo key from the repo host', () => { + expect(getRepoHostIdentity({ id: 'r', connectionId: null })).toBe('local\0r') + expect(getRepoHostIdentity({ id: 'r', connectionId: 'box 1' })).toBe('ssh:box%201\0r') + expect( + getRepoHostIdentity({ id: 'r', connectionId: null, executionHostId: 'runtime:env' }) + ).toBe('runtime:env\0r') + expectTypeOf(getRepoHostIdentity({ id: 'r' })).toEqualTypeOf() + }) +}) diff --git a/src/renderer/src/store/slices/repo-host-identity.ts b/src/renderer/src/store/slices/repo-host-identity.ts index a55198fd85c..140fe4ec8e1 100644 --- a/src/renderer/src/store/slices/repo-host-identity.ts +++ b/src/renderer/src/store/slices/repo-host-identity.ts @@ -5,13 +5,16 @@ import { getSettingsFocusedExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' -import { getRepoHostIdentityForParts } from '../../../../shared/repo-host-identity' +import { + getRepoHostIdentityForParts, + type HostScopedRepoKey +} from '../../../../shared/repo-host-identity' -export { getRepoHostIdentityForParts } +export { getRepoHostIdentityForParts, type HostScopedRepoKey } type RepoIdentityParts = Pick -export function getRepoHostIdentity(repo: RepoIdentityParts): string { +export function getRepoHostIdentity(repo: RepoIdentityParts): HostScopedRepoKey { return getRepoHostIdentityForParts(repo.id, getRepoExecutionHostId(repo)) } diff --git a/src/shared/repo-host-identity.test.ts b/src/shared/repo-host-identity.test.ts new file mode 100644 index 00000000000..0b61e82f6ab --- /dev/null +++ b/src/shared/repo-host-identity.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import { toRuntimeExecutionHostId, toSshExecutionHostId } from './execution-host' +import { getRepoHostIdentityForParts, type HostScopedRepoKey } from './repo-host-identity' + +// Why: these keys index persisted and cross-module repo state, so the bytes are frozen (D25). +describe('repo host identity golden bytes', () => { + it.each([ + ['repo-1', 'local', 'local\0repo-1'], + [ + 'repo-1', + toSshExecutionHostId('deploy@10.0.0.4:2222'), + 'ssh:deploy%4010.0.0.4%3A2222\0repo-1' + ], + ['repo-1', toRuntimeExecutionHostId('env 1/a'), 'runtime:env%201%2Fa\0repo-1'], + ['repo|1::x', 'local', 'local\0repo|1::x'], + ['repo-1', '', '\0repo-1'] + ] as const)('composes %s on %s', (repoId, hostId, expected) => { + expect(getRepoHostIdentityForParts(repoId, hostId)).toBe(expected) + }) + + it('brands the key so a plain string cannot pose as one', () => { + const key = getRepoHostIdentityForParts('repo-1', 'local') + expectTypeOf(key).toEqualTypeOf() + expectTypeOf().toExtend() + expectTypeOf().not.toExtend() + const raw: string = key + // @ts-expect-error a raw string is not a host-scoped key + const forged: HostScopedRepoKey = 'local\0repo-1' + expect(raw).toBe(forged) + }) +}) diff --git a/src/shared/repo-host-identity.ts b/src/shared/repo-host-identity.ts index 8309899d4e6..d85c49be81c 100644 --- a/src/shared/repo-host-identity.ts +++ b/src/shared/repo-host-identity.ts @@ -1,5 +1,11 @@ -export function getRepoHostIdentityForParts(repoId: string, hostId: string): string { +declare const hostScopedRepoKeyBrand: unique symbol + +// Why: for new code only; existing raw-id stores stay as they are (D7). +export type HostScopedRepoKey = string & { readonly [hostScopedRepoKeyBrand]: true } + +export function getRepoHostIdentityForParts(repoId: string, hostId: string): HostScopedRepoKey { // Why: host ids and repo ids can contain punctuation; NUL keeps the composite // key collision-free without escaping user/provider-owned strings. - return `${hostId}\0${repoId}` + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this is the only constructor of the brand; the bytes are the unbranded composite, frozen by golden tests (D25). + return `${hostId}\0${repoId}` as HostScopedRepoKey } diff --git a/src/shared/worktree/host-qualified-identity.test.ts b/src/shared/worktree/host-qualified-identity.test.ts index f6237005b28..ec61fd8aac5 100644 --- a/src/shared/worktree/host-qualified-identity.test.ts +++ b/src/shared/worktree/host-qualified-identity.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, expectTypeOf, it } from 'vitest' import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../execution-host' import { composeWorktreeHostIdentity, getExecutionHostIdFromWorktreeHostIdentity, getWorktreeHostIdentity, - getWorktreeIdFromHostIdentity + getWorktreeIdFromHostIdentity, + type HostScopedWorktreeKey } from './host-qualified-identity' const WORKTREE_ID = 'repo-1::/work/orca' @@ -85,3 +86,37 @@ describe('worktree host identity', () => { ).toBe(toSshExecutionHostId('build-box')) }) }) + +// Why: worktreeIdentityAliases and lastVisitedAtByWorktreeId persist these keys, +// so the bytes are frozen (D25). +describe('worktree host identity golden bytes', () => { + it.each([ + ['local', 'repo::/p', 'local|repo::/p'], + [undefined, 'repo::/p', '|repo::/p'], + [ + toSshExecutionHostId('deploy@10.0.0.4:2222'), + 'repo::/p', + 'ssh:deploy%4010.0.0.4%3A2222|repo::/p' + ], + [toSshExecutionHostId('we|rd'), 'repo::/p', 'ssh:we%7Crd|repo::/p'], + [toRuntimeExecutionHostId('env 1/a'), 'repo::/p', 'runtime:env%201%2Fa|repo::/p'], + ['local', 'repo::/work/a|b', 'local|repo::/work/a|b'] + ] as const)('composes %s + %s as %s', (hostId, worktreeId, expected) => { + const key = composeWorktreeHostIdentity(hostId, worktreeId) + expect(key).toBe(expected) + expect(getWorktreeHostIdentity({ id: worktreeId, hostId })).toBe(expected) + expect(getWorktreeIdFromHostIdentity(key)).toBe(worktreeId) + expect(getExecutionHostIdFromWorktreeHostIdentity(key)).toBe(hostId) + }) + + it('brands the key so a plain string cannot pose as one', () => { + const key = composeWorktreeHostIdentity('local', WORKTREE_ID) + expectTypeOf(key).toEqualTypeOf() + expectTypeOf().toExtend() + expectTypeOf().not.toExtend() + const raw: string = key + // @ts-expect-error a raw string is not a host-scoped key + const forged: HostScopedWorktreeKey = WORKTREE_ID + expect([raw, forged]).toEqual([key, WORKTREE_ID]) + }) +}) diff --git a/src/shared/worktree/host-qualified-identity.ts b/src/shared/worktree/host-qualified-identity.ts index 4f5f2fd083e..20e10a21613 100644 --- a/src/shared/worktree/host-qualified-identity.ts +++ b/src/shared/worktree/host-qualified-identity.ts @@ -17,6 +17,11 @@ import type { Worktree } from './types' */ const HOST_SEPARATOR = '|' +declare const hostScopedWorktreeKeyBrand: unique symbol + +// Why: for new code only; existing raw-id stores stay as they are (D7). +export type HostScopedWorktreeKey = string & { readonly [hostScopedWorktreeKeyBrand]: true } + /** * Stable key for one workspace on one host (STA-4343). * @@ -27,15 +32,18 @@ const HOST_SEPARATOR = '|' * An unqualified row gets its own bucket rather than being folded into a host: * it may well BE one of them, but nothing here can prove which. */ -export function getWorktreeHostIdentity(worktree: Pick): string { +export function getWorktreeHostIdentity( + worktree: Pick +): HostScopedWorktreeKey { return composeWorktreeHostIdentity(worktree.hostId, worktree.id) } export function composeWorktreeHostIdentity( hostId: ExecutionHostId | undefined, worktreeId: string -): string { - return `${hostId ?? ''}${HOST_SEPARATOR}${worktreeId}` +): HostScopedWorktreeKey { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this is the only constructor of the brand; the bytes are the unbranded composite, frozen by golden tests (D25). + return `${hostId ?? ''}${HOST_SEPARATOR}${worktreeId}` as HostScopedWorktreeKey } /**