diff --git a/native/package.json b/native/package.json index 37dbcd7..1c2ae1f 100644 --- a/native/package.json +++ b/native/package.json @@ -1,6 +1,6 @@ { "name": "kanbangit-native", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "scripts": { diff --git a/native/publish-macos.mjs b/native/publish-macos.mjs index 8722c24..782a12e 100644 --- a/native/publish-macos.mjs +++ b/native/publish-macos.mjs @@ -10,6 +10,7 @@ import { import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import process from 'node:process'; +import { setTimeout } from 'node:timers/promises'; import { pathToFileURL } from 'node:url'; import { execute, releaseMetadata } from './macos-ci.mjs'; @@ -62,6 +63,7 @@ export async function publishMacos({ metadata, run = execute, fetchPublic = fetch, + wait = setTimeout, } = {}) { const storage = storageConfiguration(env); if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) { @@ -123,16 +125,43 @@ export async function publishMacos({ const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`; const publicPrefix = `${storage.publicBase}/${keyPrefix}`; async function verifyPublic(name, contents) { - const response = await fetchPublic(`${publicPrefix}/${name}`, { - redirect: 'error', - signal: AbortSignal.timeout(60000), - }); - if ( - !response.ok || - sha256(Buffer.from(await response.arrayBuffer())) !== - sha256(contents) - ) { - throw new Error(`Public download verification failed for ${name}.`); + for (let attempt = 1; attempt <= 5; attempt++) { + let response; + try { + response = await fetchPublic(`${publicPrefix}/${name}`, { + redirect: 'error', + signal: AbortSignal.timeout(60000), + }); + } catch (error) { + if (attempt === 5) { + throw new Error( + `Public download request failed for ${name}: ${error.message}`, + ); + } + await wait(2000); + continue; + } + if (response.ok) { + if ( + sha256(Buffer.from(await response.arrayBuffer())) !== + sha256(contents) + ) { + throw new Error( + `Public download verification failed for ${name}: SHA-256 mismatch.`, + ); + } + return; + } + await response.body?.cancel(); + const retryable = [403, 404, 408, 429, 500, 502, 503, 504].includes( + response.status, + ); + if (!retryable || attempt === 5) { + throw new Error( + `Public download verification failed for ${name}: HTTP ${response.status}.`, + ); + } + await wait(2000); } } function putImmutable(name, contents, contentType) { diff --git a/native/src-tauri/Cargo.lock b/native/src-tauri/Cargo.lock index a04422b..8828bcf 100644 --- a/native/src-tauri/Cargo.lock +++ b/native/src-tauri/Cargo.lock @@ -1673,7 +1673,7 @@ dependencies = [ [[package]] name = "kanbangit-native" -version = "0.1.0" +version = "0.1.1" dependencies = [ "serde_json", "tauri", diff --git a/native/src-tauri/Cargo.toml b/native/src-tauri/Cargo.toml index 37c8f7c..694b4f5 100644 --- a/native/src-tauri/Cargo.toml +++ b/native/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kanbangit-native" -version = "0.1.0" +version = "0.1.1" description = "KanbanGit native application" edition = "2021" publish = false diff --git a/native/src-tauri/tauri.conf.json b/native/src-tauri/tauri.conf.json index 8d27a5f..055215f 100644 --- a/native/src-tauri/tauri.conf.json +++ b/native/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "KanbanGit", - "version": "0.1.0", + "version": "0.1.1", "identifier": "de.kanbangit.desktop", "build": { "frontendDist": "../ui" diff --git a/native/tests/publish-macos.test.js b/native/tests/publish-macos.test.js index 10a0d1e..5fbc8b0 100644 --- a/native/tests/publish-macos.test.js +++ b/native/tests/publish-macos.test.js @@ -70,7 +70,16 @@ function fixture(t) { arrayBuffer: async () => objects.get(key), }; }; - return { directory, metadata, env, objects, events, run, fetchPublic }; + return { + directory, + metadata, + env, + objects, + events, + run, + fetchPublic, + wait: async () => {}, + }; } test('uploads immutable files and writes completion record after public verification', async (t) => { @@ -194,6 +203,7 @@ for (const failure of ['private', 'wrong-content', 'network']) { } return { ok: failure !== 'private', + status: failure === 'private' ? 403 : 200, arrayBuffer: async () => Buffer.from('wrong'), }; }; @@ -223,6 +233,77 @@ test('a partial upload can resume with the same files', async (t) => { assert.equal(f.objects.size, 3); }); +for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) { + test(`temporary public download failure ${status} recovers before completion`, async (t) => { + const f = fixture(t); + const fetchPublic = f.fetchPublic; + let attempts = 0; + let cancelled = 0; + const waits = []; + f.wait = async (ms) => waits.push(ms); + f.fetchPublic = async (...args) => { + if (++attempts <= 2) { + if (status === 'network') { + throw new Error('temporary network failure'); + } + return { + ok: false, + status, + body: { cancel: async () => cancelled++ }, + }; + } + return fetchPublic(...args); + }; + await publishMacos(f); + assert.deepEqual(waits, [2000, 2000]); + assert.equal(cancelled, status === 'network' ? 0 : 2); + assert.equal(f.objects.size, 3); + assert(f.events.at(-1).key.endsWith('/release.json')); + }); +} + +test('persistent public access denial stops after five attempts with HTTP status', async (t) => { + const f = fixture(t); + let attempts = 0; + const waits = []; + f.wait = async (ms) => waits.push(ms); + f.fetchPublic = async () => { + attempts++; + return { ok: false, status: 403 }; + }; + await assert.rejects(publishMacos(f), /HTTP 403/); + assert.equal(attempts, 5); + assert.equal(waits.length, 4); + assert.equal(f.objects.size, 1); +}); + +test('incorrect public bytes fail immediately without retry or completion record', async (t) => { + const f = fixture(t); + f.wait = async () => assert.fail('Do not retry mismatched content'); + f.fetchPublic = async () => ({ + ok: true, + arrayBuffer: async () => Buffer.from('incorrect'), + }); + await assert.rejects(publishMacos(f), /SHA-256 mismatch/); + assert.equal(f.objects.size, 1); +}); + +test('checksum access retries finish before publishing the completion record', async (t) => { + const f = fixture(t); + const fetchPublic = f.fetchPublic; + let failures = 0; + f.fetchPublic = async (url, options) => { + if (url.endsWith('.sha256') && ++failures <= 2) { + assert.equal(f.objects.size, 2); + return { ok: false, status: 403 }; + } + return fetchPublic(url, options); + }; + await publishMacos(f); + assert.equal(failures, 3); + assert.equal(f.objects.size, 3); +}); + for (const overrides of [ { S3_REGION: '' }, { S3_REGION: 'eu-central-1' },