From 32f445226ca6a43a1405507dd2aabf676c9b5fa8 Mon Sep 17 00:00:00 2001 From: stack72 Date: Thu, 26 Mar 2026 10:27:58 +0100 Subject: [PATCH] fix: graceful error on permission denied during swamp update ## Summary - Add pre-flight write permission check before downloading the update, so users get a clear error immediately instead of after a wasted download - Catch `PermissionDenied` in `replaceBinary()` as a safety net with the same actionable message - Error message directs users to re-run with `sudo swamp update` Fixes #870 ## Test Plan - [x] `deno check` passes - [x] `deno lint` and `deno fmt` clean - [x] All 3557 tests pass - [x] New tests: permission denied throws UserError with sudo suggestion - [x] New tests: writable path proceeds normally - [x] New tests: nonexistent binary path (fresh install) proceeds normally --- src/domain/update/update_service.ts | 23 +++++++ src/domain/update/update_service_test.ts | 60 ++++++++++++++++++- .../update/http_update_checker.ts | 8 ++- 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/domain/update/update_service.ts b/src/domain/update/update_service.ts index 8e005017..e7fd6548 100644 --- a/src/domain/update/update_service.ts +++ b/src/domain/update/update_service.ts @@ -19,6 +19,7 @@ import type { Platform } from "./platform.ts"; import { validateRedirectUrl } from "./integrity.ts"; +import { UserError } from "../errors.ts"; /** * Port for checking and performing updates. @@ -146,11 +147,33 @@ export class UpdateService { }; } + /** + * Check whether the process can write to the binary path. + * Throws a UserError with remediation advice if not. + */ + async checkWritePermission(): Promise { + try { + // Try opening the file for writing without truncating — this tests + // actual write permission without modifying the file. + const file = await Deno.open(this.binaryPath, { write: true }); + file.close(); + } catch (error) { + if (error instanceof Deno.errors.PermissionDenied) { + throw new UserError( + `Cannot update ${this.binaryPath}: permission denied. Re-run with: sudo swamp update`, + ); + } + // Other errors (e.g. NotFound) are fine — the file may not exist yet + } + } + /** * Check for and install an update. * Downloads from the resolved versioned URL, not the stable redirect pointer. */ async update(platform: Platform): Promise { + await this.checkWritePermission(); + const redirectUrl = await this.checker.checkForUpdate(platform); if (!redirectUrl) { return { diff --git a/src/domain/update/update_service_test.ts b/src/domain/update/update_service_test.ts index a3b55f5c..0bf8d336 100644 --- a/src/domain/update/update_service_test.ts +++ b/src/domain/update/update_service_test.ts @@ -17,7 +17,7 @@ // You should have received a copy of the GNU Affero General Public License // along with Swamp. If not, see . -import { assertEquals, assertRejects } from "@std/assert"; +import { assertEquals, assertRejects, assertStringIncludes } from "@std/assert"; import { isDevBuild, parseVersionFromRedirectUrl, @@ -277,3 +277,61 @@ Deno.test("update fetches checksum and passes it to downloadAndInstall", async ( assertEquals(calls.checksumUrls, [redirectUrl]); assertEquals(calls.expectedChecksums, [expectedHash]); }); + +// --- Pre-flight permission check tests --- + +Deno.test("update rejects with UserError when binary path is not writable", async () => { + const tempDir = await Deno.makeTempDir(); + const readOnlyFile = `${tempDir}/swamp`; + await Deno.writeTextFile(readOnlyFile, "binary"); + await Deno.chmod(readOnlyFile, 0o444); + + const redirectUrl = + "https://artifacts.systeminit.com/swamp/20260208.000000.0-sha.def56789/binary/darwin/aarch64/swamp-stable-binary-darwin-aarch64.tar.gz"; + const service = new UpdateService( + createMockChecker(redirectUrl), + "20260207.123456.0-sha.abc12345", + readOnlyFile, + ); + + const error = await assertRejects( + () => service.update(platform), + UserError, + ); + assertStringIncludes(error.message, "permission denied"); + assertStringIncludes(error.message, "sudo swamp update"); + + // Cleanup + await Deno.chmod(readOnlyFile, 0o644); + await Deno.remove(tempDir, { recursive: true }); +}); + +Deno.test("update proceeds when binary path is writable", async () => { + const tempDir = await Deno.makeTempDir(); + const writableFile = `${tempDir}/swamp`; + await Deno.writeTextFile(writableFile, "binary"); + + const redirectUrl = + "https://artifacts.systeminit.com/swamp/20260208.000000.0-sha.def56789/binary/darwin/aarch64/swamp-stable-binary-darwin-aarch64.tar.gz"; + const service = new UpdateService( + createMockChecker(redirectUrl), + "20260207.123456.0-sha.abc12345", + writableFile, + ); + + const result = await service.update(platform); + assertEquals(result.status, "updated"); + + await Deno.remove(tempDir, { recursive: true }); +}); + +Deno.test("update proceeds when binary does not exist yet", async () => { + const service = new UpdateService( + createMockChecker(null), + "20260207.123456.0-sha.abc12345", + "/tmp/nonexistent-swamp-binary-path", + ); + + const result = await service.update(platform); + assertEquals(result.status, "up_to_date"); +}); diff --git a/src/infrastructure/update/http_update_checker.ts b/src/infrastructure/update/http_update_checker.ts index 42809e6c..f5043ed4 100644 --- a/src/infrastructure/update/http_update_checker.ts +++ b/src/infrastructure/update/http_update_checker.ts @@ -68,7 +68,13 @@ async function replaceBinary( await Deno.remove(targetPath); } catch (error) { // NotFound is fine — target may not exist yet - if (!(error instanceof Deno.errors.NotFound)) { + if (error instanceof Deno.errors.NotFound) { + // OK + } else if (error instanceof Deno.errors.PermissionDenied) { + throw new UserError( + `Cannot update ${targetPath}: permission denied. Re-run with: sudo swamp update`, + ); + } else { throw error; } }