From 86b5941875bb16e28981b09aca91c38424ef50ac Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 27 Apr 2026 18:06:48 -0400 Subject: [PATCH] feat(ext/sw): GitHost.writeFileCreateOnly() refuses to overwrite Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/git-host-extensions.test.ts | 50 +++++++++++++++++++ extension/src/service-worker/git-host.ts | 5 ++ extension/src/service-worker/gitea.ts | 17 +++++++ extension/src/service-worker/github.ts | 17 +++++++ 4 files changed, 89 insertions(+) diff --git a/extension/src/service-worker/__tests__/git-host-extensions.test.ts b/extension/src/service-worker/__tests__/git-host-extensions.test.ts index afbebeb..259e949 100644 --- a/extension/src/service-worker/__tests__/git-host-extensions.test.ts +++ b/extension/src/service-worker/__tests__/git-host-extensions.test.ts @@ -59,3 +59,53 @@ describe('lastCommit (GitHub)', () => { expect(await host.lastCommit('manifest.enc')).toBeNull(); }); }); + +describe('writeFileCreateOnly (Gitea)', () => { + let fetchSpy: ReturnType; + beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); }); + + it('creates when file does not exist', async () => { + fetchSpy + .mockResolvedValueOnce(new Response('', { status: 404 })) // pre-check + .mockResolvedValueOnce(new Response('{}', { status: 201 })); // POST + const host = new GiteaHost('https://git.example.com', 'user/vault', 'tok'); + await host.writeFileCreateOnly('manifest.enc', new Uint8Array([1, 2, 3]), 'init'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect((fetchSpy.mock.calls[1][1] as RequestInit).method).toBe('POST'); + }); + + it('throws when file already exists', async () => { + fetchSpy.mockResolvedValueOnce(new Response( + JSON.stringify({ sha: 'abc' }), { status: 200 }, + )); + const host = new GiteaHost('https://git.example.com', 'user/vault', 'tok'); + await expect( + host.writeFileCreateOnly('manifest.enc', new Uint8Array([1]), 'init'), + ).rejects.toThrow(/already exists/); + expect(fetchSpy).toHaveBeenCalledTimes(1); // pre-check only, no write + }); +}); + +describe('writeFileCreateOnly (GitHub)', () => { + let fetchSpy: ReturnType; + beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); }); + + it('throws when file already exists', async () => { + fetchSpy.mockResolvedValueOnce(new Response( + JSON.stringify({ sha: 'abc' }), { status: 200 }, + )); + const host = new GitHubHost('user/vault', 'tok'); + await expect( + host.writeFileCreateOnly('manifest.enc', new Uint8Array([1]), 'init'), + ).rejects.toThrow(/already exists/); + }); + + it('creates when file does not exist', async () => { + fetchSpy + .mockResolvedValueOnce(new Response('', { status: 404 })) + .mockResolvedValueOnce(new Response('{}', { status: 201 })); + const host = new GitHubHost('user/vault', 'tok'); + await host.writeFileCreateOnly('manifest.enc', new Uint8Array([1]), 'init'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/extension/src/service-worker/git-host.ts b/extension/src/service-worker/git-host.ts index ef4e071..d817e81 100644 --- a/extension/src/service-worker/git-host.ts +++ b/extension/src/service-worker/git-host.ts @@ -11,6 +11,11 @@ export interface GitHost { /// Create or update a file in the repo with a commit message. writeFile(path: string, content: Uint8Array, message: string): Promise; + /// Like writeFile, but throws if the file already exists. Used by setup + /// wizard to refuse to clobber existing vault state. Implementation must + /// pre-check existence and only POST/PUT-create — never include a sha. + writeFileCreateOnly(path: string, content: Uint8Array, message: string): Promise; + /// Delete a file from the repo with a commit message. deleteFile(path: string, message: string): Promise; diff --git a/extension/src/service-worker/gitea.ts b/extension/src/service-worker/gitea.ts index c8368b0..08abf22 100644 --- a/extension/src/service-worker/gitea.ts +++ b/extension/src/service-worker/gitea.ts @@ -82,6 +82,23 @@ export class GiteaHost implements GitHost { } } + async writeFileCreateOnly(path: string, content: Uint8Array, message: string): Promise { + const existing = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (existing.ok) { + throw new Error(`writeFileCreateOnly: ${path} already exists`); + } + const b64 = uint8ArrayToBase64(content); + const resp = await fetch(`${this.baseUrl}/${path}`, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ content: b64, message }), + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`Gitea writeFileCreateOnly ${path}: ${resp.status} ${text}`); + } + } + async deleteFile(path: string, message: string): Promise { // Need the current SHA to delete. const existing = await fetch(`${this.baseUrl}/${path}`, { diff --git a/extension/src/service-worker/github.ts b/extension/src/service-worker/github.ts index 709472d..e1a3d56 100644 --- a/extension/src/service-worker/github.ts +++ b/extension/src/service-worker/github.ts @@ -76,6 +76,23 @@ export class GitHubHost implements GitHost { } } + async writeFileCreateOnly(path: string, content: Uint8Array, message: string): Promise { + const existing = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (existing.ok) { + throw new Error(`writeFileCreateOnly: ${path} already exists`); + } + const b64 = uint8ArrayToBase64(content); + const resp = await fetch(`${this.baseUrl}/${path}`, { + method: 'PUT', + headers: this.headers, + body: JSON.stringify({ content: b64, message }), // no sha → create-only + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`GitHub writeFileCreateOnly ${path}: ${resp.status} ${text}`); + } + } + async deleteFile(path: string, message: string): Promise { const existing = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers,