From 6729a2d9917432391f510697bd000b7c76147ff9 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Thu, 16 Apr 2026 03:07:07 -0700 Subject: [PATCH] fix(recover): wrap embedded-manifest atob in try/catch for corrupt base64 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit atob() throws DOMException when its input isn't valid base64. The embedded manifest lives in recover.html, which is meant to be archived long-term; bit rot, truncated copies, or storage degradation can leave the base64 payload unreadable. Previously the exception propagated, leaving Step 2 asking the user to load MANIFEST.age manually — but the whole point of embedding was to avoid needing the separate file. Catch the decode failure and surface a toast so the user knows their copy of recover.html is damaged and to try another copy. The i18n function t() already falls back to English when a locale is missing translations, so the three new keys work across all languages even before translators catch up. Fixes #107 --- internal/html/assets/src/app.ts | 26 ++++++++++++++++++++------ internal/translations/recover/en.json | 3 +++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/internal/html/assets/src/app.ts b/internal/html/assets/src/app.ts index 2b1a7811..03677185 100644 --- a/internal/html/assets/src/app.ts +++ b/internal/html/assets/src/app.ts @@ -341,13 +341,27 @@ type UIShare = ParsedShare & { isHolder?: boolean }; // Load embedded manifest if available (included when MANIFEST.age is small enough) if (personalization.manifestB64) { - const binary = atob(personalization.manifestB64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); + try { + const binary = atob(personalization.manifestB64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + state.manifest = bytes; + showManifestLoaded('MANIFEST.age', state.manifest.length, 'embedded'); + } catch (err) { + // atob throws DOMException on corrupt base64 (bit rot, truncated copy, + // storage degradation). Surface it so the user knows to try another + // copy of recover.html rather than assuming they need MANIFEST.age + // separately. + showError( + t('error_corrupt_embedded_manifest_message'), + { + title: t('error_corrupt_embedded_manifest_title'), + guidance: t('error_corrupt_embedded_manifest_guidance'), + } + ); } - state.manifest = bytes; - showManifestLoaded('MANIFEST.age', state.manifest.length, 'embedded'); } checkRecoverReady(); diff --git a/internal/translations/recover/en.json b/internal/translations/recover/en.json index 87128145..3542ccd1 100644 --- a/internal/translations/recover/en.json +++ b/internal/translations/recover/en.json @@ -77,6 +77,9 @@ "error_extract_status": "Extraction failed. The archive may be corrupted.", "error_recovery_title": "Recovery failed", "error_recovery_guidance": "Check that you have the correct pieces and the right MANIFEST.age file. You can try again with different pieces.", + "error_corrupt_embedded_manifest_title": "Corrupted embedded archive", + "error_corrupt_embedded_manifest_message": "The archive embedded in this recover.html is damaged and couldn't be loaded.", + "error_corrupt_embedded_manifest_guidance": "Your copy of recover.html may be truncated or corrupted. Try another copy of the file, or load MANIFEST.age manually in Step 2.", "action_reload": "Reload page", "action_use_cli": "Use CLI tool", "action_try_different_shares": "Try different pieces",