Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/domain/update/update_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<void> {
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<UpdateResult> {
await this.checkWritePermission();

const redirectUrl = await this.checker.checkForUpdate(platform);
if (!redirectUrl) {
return {
Expand Down
60 changes: 59 additions & 1 deletion src/domain/update/update_service_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with Swamp. If not, see <https://www.gnu.org/licenses/>.

import { assertEquals, assertRejects } from "@std/assert";
import { assertEquals, assertRejects, assertStringIncludes } from "@std/assert";
import {
isDevBuild,
parseVersionFromRedirectUrl,
Expand Down Expand Up @@ -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");
});
8 changes: 7 additions & 1 deletion src/infrastructure/update/http_update_checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
Loading