Skip to content

Commit 37bfe39

Browse files
committed
Add managed install checksum helpers
Refs #10 Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent 54a4b93 commit 37bfe39

3 files changed

Lines changed: 214 additions & 1 deletion

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ Managed binary downloads, MCP config injection, and marketplace publishing workf
1919

2020
The extension now has managed-install groundwork for release asset targeting and status reporting, using VS Code global storage for future managed binaries, but it does not download binaries yet.
2121

22+
## Managed install security model
23+
24+
The managed installer work is intentionally conservative:
25+
26+
- downloads must come from `https://github.com/patchloom/patchloom/releases/download/...`
27+
- each downloaded archive must match a published SHA-256 checksum before the extension can trust it
28+
- checksum verification failures must stop the install before any managed binary is used
29+
- a failed verification or failed install must not replace an already working Patchloom binary
30+
- verification failures should stay diagnosable through explicit user-facing status and error messages
31+
2232
## Commands
2333

2434
- `Patchloom: Show Status`

src/install/managed.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from "node:crypto";
12
import * as path from "node:path";
23

34
export const PATCHLOOM_RELEASE_REPO = "patchloom/patchloom";
@@ -54,6 +55,27 @@ export interface ManagedInstallStatusInputs {
5455
readonly fileExists?: (filePath: string) => Promise<boolean>;
5556
}
5657

58+
export interface ManagedInstallChecksumEntry {
59+
readonly fileName: string;
60+
readonly sha256: string;
61+
}
62+
63+
export type ManagedInstallVerificationFailureReason =
64+
| "missing-checksum"
65+
| "invalid-checksum-format"
66+
| "checksum-mismatch"
67+
| "untrusted-download-url";
68+
69+
export class ManagedInstallVerificationError extends Error {
70+
readonly reason: ManagedInstallVerificationFailureReason;
71+
72+
constructor(reason: ManagedInstallVerificationFailureReason, message: string) {
73+
super(message);
74+
this.name = "ManagedInstallVerificationError";
75+
this.reason = reason;
76+
}
77+
}
78+
5779
export function setManagedInstallRoot(root: string | undefined): void {
5880
managedInstallRoot = root;
5981
}
@@ -149,6 +171,97 @@ export function buildManagedInstallReleaseAssets(
149171
};
150172
}
151173

174+
export function parseManagedInstallChecksumFile(content: string): ManagedInstallChecksumEntry[] {
175+
const entries: ManagedInstallChecksumEntry[] = [];
176+
177+
for (const rawLine of content.split(/\r?\n/)) {
178+
const line = rawLine.trim();
179+
if (!line) {
180+
continue;
181+
}
182+
183+
const match = line.match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
184+
if (!match) {
185+
throw new ManagedInstallVerificationError(
186+
"invalid-checksum-format",
187+
`Invalid checksum line: ${rawLine}`
188+
);
189+
}
190+
191+
entries.push({
192+
sha256: match[1].toLowerCase(),
193+
fileName: match[2].trim()
194+
});
195+
}
196+
197+
return entries;
198+
}
199+
200+
export function resolveManagedInstallChecksum(
201+
checksumFileContent: string,
202+
archiveFileName: string
203+
): string {
204+
const entry = parseManagedInstallChecksumFile(checksumFileContent)
205+
.find((candidate) => candidate.fileName === archiveFileName);
206+
207+
if (!entry) {
208+
throw new ManagedInstallVerificationError(
209+
"missing-checksum",
210+
`Missing checksum entry for ${archiveFileName}.`
211+
);
212+
}
213+
214+
return entry.sha256;
215+
}
216+
217+
export function calculateSha256Hex(content: string | Uint8Array): string {
218+
return createHash("sha256").update(content).digest("hex");
219+
}
220+
221+
export function verifyManagedInstallArchiveChecksum(
222+
archiveContent: string | Uint8Array,
223+
checksumFileContent: string,
224+
archiveFileName: string
225+
): string {
226+
const expectedSha256 = resolveManagedInstallChecksum(checksumFileContent, archiveFileName);
227+
const actualSha256 = calculateSha256Hex(archiveContent);
228+
229+
if (expectedSha256 !== actualSha256) {
230+
throw new ManagedInstallVerificationError(
231+
"checksum-mismatch",
232+
`Checksum mismatch for ${archiveFileName}. Expected ${expectedSha256}, got ${actualSha256}.`
233+
);
234+
}
235+
236+
return actualSha256;
237+
}
238+
239+
export function isTrustedManagedInstallDownloadUrl(
240+
url: string,
241+
repo = PATCHLOOM_RELEASE_REPO
242+
): boolean {
243+
try {
244+
const parsed = new URL(url);
245+
return parsed.protocol === "https:"
246+
&& parsed.hostname === "github.com"
247+
&& parsed.pathname.startsWith(`/${repo}/releases/download/`);
248+
} catch {
249+
return false;
250+
}
251+
}
252+
253+
export function assertTrustedManagedInstallDownloadUrl(
254+
url: string,
255+
repo = PATCHLOOM_RELEASE_REPO
256+
): void {
257+
if (!isTrustedManagedInstallDownloadUrl(url, repo)) {
258+
throw new ManagedInstallVerificationError(
259+
"untrusted-download-url",
260+
`Managed install downloads must come from https://github.com/${repo}/releases/download/.`
261+
);
262+
}
263+
}
264+
152265
export async function inspectManagedInstallStatus(
153266
inputs: ManagedInstallStatusInputs
154267
): Promise<ManagedInstallStatus | undefined> {

test/unit/binary.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,18 @@ import {
99
resolvePatchloomStatusWithInputs
1010
} from "../../src/binary/patchloom";
1111
import {
12+
assertTrustedManagedInstallDownloadUrl,
1213
buildManagedInstallReleaseAssets,
14+
calculateSha256Hex,
1315
detectManagedInstallTarget,
1416
inspectManagedInstallStatus,
17+
ManagedInstallVerificationError,
1518
normalizeReleaseVersion,
19+
parseManagedInstallChecksumFile,
1620
PATCHLOOM_MANAGED_INSTALL_DIR,
17-
resolveManagedInstallPaths
21+
resolveManagedInstallChecksum,
22+
resolveManagedInstallPaths,
23+
verifyManagedInstallArchiveChecksum
1824
} from "../../src/install/managed";
1925
import { resolveMcpTargets } from "../../src/mcp/config";
2026
import { defaultWorkspaceFolderIndex, describeWorkspaceEnvironment } from "../../src/workspace/readiness";
@@ -163,6 +169,90 @@ test("buildManagedInstallReleaseAssets builds archive and checksum urls", () =>
163169
assert.match(release.checksumDownloadUrl, /patchloom-x86_64-unknown-linux-gnu\.tar\.xz\.sha256$/);
164170
});
165171

172+
test("parseManagedInstallChecksumFile accepts common sha256 sidecar formats", () => {
173+
const entries = parseManagedInstallChecksumFile([
174+
"ece89100861aa6d4c7e409f279777d1619f8d86e0f67f396fa9f3e4535eb2f0e patchloom-aarch64-apple-darwin.tar.xz",
175+
"1dc508c14f9b3584c992c61dcf8d18d8d3a6770f33fff241038c1e9ea7b27a97 *patchloom-x86_64-unknown-linux-gnu.tar.xz"
176+
].join("\n"));
177+
178+
assert.deepEqual(entries, [
179+
{
180+
sha256: "ece89100861aa6d4c7e409f279777d1619f8d86e0f67f396fa9f3e4535eb2f0e",
181+
fileName: "patchloom-aarch64-apple-darwin.tar.xz"
182+
},
183+
{
184+
sha256: "1dc508c14f9b3584c992c61dcf8d18d8d3a6770f33fff241038c1e9ea7b27a97",
185+
fileName: "patchloom-x86_64-unknown-linux-gnu.tar.xz"
186+
}
187+
]);
188+
});
189+
190+
test("resolveManagedInstallChecksum returns the matching archive checksum", () => {
191+
const checksum = resolveManagedInstallChecksum(
192+
[
193+
"ece89100861aa6d4c7e409f279777d1619f8d86e0f67f396fa9f3e4535eb2f0e patchloom-aarch64-apple-darwin.tar.xz",
194+
"1dc508c14f9b3584c992c61dcf8d18d8d3a6770f33fff241038c1e9ea7b27a97 patchloom-x86_64-unknown-linux-gnu.tar.xz"
195+
].join("\n"),
196+
"patchloom-x86_64-unknown-linux-gnu.tar.xz"
197+
);
198+
199+
assert.equal(checksum, "1dc508c14f9b3584c992c61dcf8d18d8d3a6770f33fff241038c1e9ea7b27a97");
200+
});
201+
202+
test("verifyManagedInstallArchiveChecksum validates archive content against the checksum sidecar", () => {
203+
const checksum = verifyManagedInstallArchiveChecksum(
204+
"patchloom",
205+
"ece89100861aa6d4c7e409f279777d1619f8d86e0f67f396fa9f3e4535eb2f0e patchloom-aarch64-apple-darwin.tar.xz",
206+
"patchloom-aarch64-apple-darwin.tar.xz"
207+
);
208+
209+
assert.equal(checksum, calculateSha256Hex("patchloom"));
210+
});
211+
212+
test("verifyManagedInstallArchiveChecksum rejects mismatched archive content", () => {
213+
assert.throws(
214+
() => verifyManagedInstallArchiveChecksum(
215+
"managed-install",
216+
"ece89100861aa6d4c7e409f279777d1619f8d86e0f67f396fa9f3e4535eb2f0e patchloom-aarch64-apple-darwin.tar.xz",
217+
"patchloom-aarch64-apple-darwin.tar.xz"
218+
),
219+
(error: unknown) => {
220+
assert.ok(error instanceof ManagedInstallVerificationError);
221+
assert.equal(error.reason, "checksum-mismatch");
222+
assert.match(error.message, /checksum mismatch/i);
223+
return true;
224+
}
225+
);
226+
});
227+
228+
test("parseManagedInstallChecksumFile rejects invalid lines", () => {
229+
assert.throws(
230+
() => parseManagedInstallChecksumFile("not-a-checksum patchloom-aarch64-apple-darwin.tar.xz"),
231+
(error: unknown) => {
232+
assert.ok(error instanceof ManagedInstallVerificationError);
233+
assert.equal(error.reason, "invalid-checksum-format");
234+
return true;
235+
}
236+
);
237+
});
238+
239+
test("assertTrustedManagedInstallDownloadUrl only accepts GitHub release download urls", () => {
240+
assert.doesNotThrow(() => assertTrustedManagedInstallDownloadUrl(
241+
"https://github.com/patchloom/patchloom/releases/download/v0.1.0/patchloom-aarch64-apple-darwin.tar.xz"
242+
));
243+
244+
assert.throws(
245+
() => assertTrustedManagedInstallDownloadUrl(
246+
"https://example.com/patchloom-aarch64-apple-darwin.tar.xz"
247+
),
248+
(error: unknown) => {
249+
assert.ok(error instanceof ManagedInstallVerificationError);
250+
assert.equal(error.reason, "untrusted-download-url");
251+
return true;
252+
}
253+
);
254+
});
255+
166256
test("managed install constants use a stable storage directory name", () => {
167257
assert.equal(PATCHLOOM_MANAGED_INSTALL_DIR, "patchloom-managed");
168258
});

0 commit comments

Comments
 (0)