Skip to content

Commit f2f2205

Browse files
[Improve] Harden DCG binary installation (#1060)
* refactor: extract managed binary installation infrastructure Refs #1055 * fix: address managed binary review feedback * feat: add destructive command guard binary service Refs #1056 * test: strengthen DCG binary service coverage * fix: address DCG service review feedback * fix: address managed binary review feedback * test: cover managed binary cleanup boundaries * fix: address DCG binary service feedback * fix: finalize managed binary download handling * test: mirror download stream close events --------- Co-authored-by: Naved Merchant <14171946+navedmerchant@users.noreply.github.com>
1 parent 7918f6b commit f2f2205

5 files changed

Lines changed: 802 additions & 0 deletions

File tree

Lines changed: 387 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
import { createHash } from "crypto"
2+
import { EventEmitter } from "events"
3+
import { access, chmod, mkdtemp, mkdir, readFile, rm, stat, writeFile } from "fs/promises"
4+
import { tmpdir } from "os"
5+
import path from "path"
6+
import { PassThrough } from "stream"
7+
8+
import { spawn } from "child_process"
9+
import { get } from "https"
10+
import type { IncomingMessage, RequestOptions } from "http"
11+
12+
import { DCG_ARCHIVES, DCG_VERSION } from "../constants"
13+
import {
14+
downloadFile,
15+
extractSingleBinary,
16+
getDcgArchiveInfo,
17+
getDcgBinaryPath,
18+
isDcgSupportedPlatform,
19+
isTrustedDownloadUrl,
20+
resolveTrustedRedirect,
21+
ensureDcgInstalled,
22+
verifyChecksum,
23+
} from "../manager"
24+
25+
vi.mock("child_process", () => ({ spawn: vi.fn() }))
26+
vi.mock("https", () => ({ get: vi.fn() }))
27+
28+
const mockSpawn = vi.mocked(spawn)
29+
const mockGet = vi.mocked(get)
30+
31+
describe("Destructive Command Guard manager", () => {
32+
let tempDir: string
33+
34+
beforeEach(async () => {
35+
tempDir = await mkdtemp(path.join(tmpdir(), "dcg-manager-"))
36+
mockSpawn.mockReset()
37+
mockGet.mockReset()
38+
})
39+
40+
afterEach(async () => {
41+
await rm(tempDir, { recursive: true, force: true })
42+
})
43+
44+
it("maps all supported platform and architecture combinations", () => {
45+
expect(Object.keys(DCG_ARCHIVES).sort()).toEqual(["darwin-arm64", "linux-arm64", "linux-x64", "win32-x64"])
46+
expect(getDcgArchiveInfo("darwin", "arm64")?.archive).toBe("dcg-aarch64-apple-darwin.tar.xz")
47+
expect(getDcgArchiveInfo("win32", "x64")?.binary).toBe("dcg.exe")
48+
})
49+
50+
it("rejects unsupported platforms", () => {
51+
expect(isDcgSupportedPlatform("freebsd", "x64")).toBe(false)
52+
expect(getDcgBinaryPath("/storage", "freebsd", "x64")).toBeUndefined()
53+
})
54+
55+
it("returns the managed binary path", () => {
56+
expect(getDcgBinaryPath("/storage", "linux", "x64")).toBe(
57+
path.join("/storage", "destructive-command-guard", "dcg"),
58+
)
59+
})
60+
61+
it("accepts only HTTPS URLs on trusted host boundaries", () => {
62+
expect(isTrustedDownloadUrl("https://github.com/release")).toBe(true)
63+
expect(isTrustedDownloadUrl("https://cdn.objects.githubusercontent.com/release")).toBe(true)
64+
expect(isTrustedDownloadUrl("http://github.com/release")).toBe(false)
65+
expect(isTrustedDownloadUrl("https://evilgithub.com/release")).toBe(false)
66+
expect(isTrustedDownloadUrl("https://github.com.evil.com/release")).toBe(false)
67+
expect(isTrustedDownloadUrl("not a URL")).toBe(false)
68+
})
69+
70+
it("rejects untrusted download URLs before opening a destination", async () => {
71+
await expect(downloadFile("https://example.com/dcg", path.join(tempDir, "archive"))).rejects.toThrow(
72+
"DCG download URL is not a trusted HTTPS host",
73+
)
74+
})
75+
76+
it("rejects non-successful HTTP responses", async () => {
77+
const response = Object.assign(new PassThrough(), { statusCode: 503, headers: {}, destroy: vi.fn() })
78+
const request = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() })
79+
mockGet.mockImplementation((_url, optionsOrCallback, optionalCallback) => {
80+
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback
81+
setImmediate(() => callback?.(response as unknown as IncomingMessage))
82+
return request as unknown as ReturnType<typeof get>
83+
})
84+
85+
await expect(downloadFile("https://github.com/release", path.join(tempDir, "archive"))).rejects.toThrow(
86+
"DCG download failed with HTTP 503",
87+
)
88+
})
89+
90+
it("rejects request errors", async () => {
91+
const request = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() })
92+
mockGet.mockReturnValue(request as unknown as ReturnType<typeof get>)
93+
94+
const download = downloadFile("https://github.com/release", path.join(tempDir, "archive"))
95+
request.emit("error", new Error("socket failed"))
96+
97+
await expect(download).rejects.toThrow("socket failed")
98+
})
99+
100+
it("times out stalled requests", async () => {
101+
const request = Object.assign(new EventEmitter(), {
102+
setTimeout: vi.fn((_timeout: number, callback: () => void) => setImmediate(callback)),
103+
destroy: vi.fn((error: Error) => request.emit("error", error)),
104+
})
105+
mockGet.mockReturnValue(request as unknown as ReturnType<typeof get>)
106+
107+
await expect(downloadFile("https://github.com/release", path.join(tempDir, "archive"))).rejects.toThrow(
108+
"DCG download timed out",
109+
)
110+
expect(request.setTimeout).toHaveBeenCalledWith(120_000, expect.any(Function))
111+
})
112+
113+
it("rejects archives larger than 50 MiB", async () => {
114+
const response = Object.assign(new PassThrough(), {
115+
statusCode: 200,
116+
headers: { "content-length": String(50 * 1024 * 1024 + 1) },
117+
destroy: vi.fn(),
118+
})
119+
const request = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() })
120+
mockGet.mockImplementation((_url, optionsOrCallback, optionalCallback) => {
121+
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback
122+
setImmediate(() => callback?.(response as unknown as IncomingMessage))
123+
return request as unknown as ReturnType<typeof get>
124+
})
125+
126+
await expect(downloadFile("https://github.com/release", path.join(tempDir, "archive"))).rejects.toThrow(
127+
"DCG archive exceeds the download size limit",
128+
)
129+
})
130+
131+
it("allows trusted relative redirects and rejects unsafe or exhausted redirects", () => {
132+
expect(resolveTrustedRedirect("https://github.com/release", "/asset", 5)).toBe("https://github.com/asset")
133+
expect(
134+
resolveTrustedRedirect(
135+
"https://github.com/release",
136+
"https://release-assets.githubusercontent.com/asset",
137+
5,
138+
),
139+
).toBe("https://release-assets.githubusercontent.com/asset")
140+
expect(() => resolveTrustedRedirect("https://github.com/release", "https://example.com/asset", 5)).toThrow(
141+
"DCG download redirected to an untrusted host",
142+
)
143+
expect(() => resolveTrustedRedirect("https://github.com/release", "/asset", 0)).toThrow(
144+
"Too many DCG download redirects",
145+
)
146+
expect(() => resolveTrustedRedirect("https://github.com/release", undefined, 5)).toThrow(
147+
"DCG download redirect is missing a Location header",
148+
)
149+
})
150+
151+
it("verifies matching checksums and rejects mismatches", async () => {
152+
const filePath = path.join(tempDir, "archive")
153+
const contents = Buffer.from("verified archive")
154+
await writeFile(filePath, contents)
155+
const checksum = createHash("sha256").update(contents).digest("hex")
156+
157+
await expect(verifyChecksum(filePath, checksum)).resolves.toBeUndefined()
158+
await expect(verifyChecksum(filePath, "0".repeat(64))).rejects.toThrow(`got ${checksum}`)
159+
})
160+
161+
it("uses the platform ZIP extractor", async () => {
162+
const child = Object.assign(new EventEmitter(), {
163+
stdout: new PassThrough(),
164+
stderr: new PassThrough(),
165+
kill: vi.fn(),
166+
})
167+
// The production code uses only the event and stream subset supplied by this test double.
168+
mockSpawn.mockReturnValue(child as unknown as ReturnType<typeof spawn>)
169+
170+
const extraction = extractSingleBinary("C:\\dcg.zip", "C:\\staging", DCG_ARCHIVES["win32-x64"])
171+
child.emit("close", 0)
172+
await extraction
173+
174+
const expectedExecutable = process.platform === "win32" ? "powershell" : "unzip"
175+
const expectedArgs =
176+
process.platform === "win32"
177+
? [
178+
"-NoProfile",
179+
"-NonInteractive",
180+
"-Command",
181+
"$archivePath = $args[0]; $destination = $args[1]; Expand-Archive -LiteralPath $archivePath -DestinationPath $destination -Force",
182+
"C:\\dcg.zip",
183+
"C:\\staging",
184+
]
185+
: ["-o", "C:\\dcg.zip", "-d", "C:\\staging"]
186+
187+
expect(mockSpawn).toHaveBeenCalledWith(expectedExecutable, expectedArgs, {
188+
shell: false,
189+
stdio: ["ignore", "pipe", "pipe"],
190+
})
191+
})
192+
193+
it("extracts tar archives without imposing a single-file layout", async () => {
194+
const child = Object.assign(new EventEmitter(), {
195+
stdout: new PassThrough(),
196+
stderr: new PassThrough(),
197+
kill: vi.fn(),
198+
})
199+
// The production code uses only the event and stream subset supplied by this test double.
200+
mockSpawn.mockReturnValue(child as unknown as ReturnType<typeof spawn>)
201+
202+
const extraction = extractSingleBinary("/tmp/dcg.tar.xz", tempDir, DCG_ARCHIVES["linux-x64"])
203+
child.emit("close", 0)
204+
205+
await expect(extraction).resolves.toBeUndefined()
206+
expect(mockSpawn).toHaveBeenCalledTimes(1)
207+
const expectedArgs = ["-xJf", "/tmp/dcg.tar.xz", "-C", tempDir, "--no-same-owner"]
208+
if (process.platform === "linux") expectedArgs.push("--no-overwrite-dir")
209+
expect(mockSpawn).toHaveBeenCalledWith("tar", expectedArgs, { shell: false, stdio: ["ignore", "pipe", "pipe"] })
210+
})
211+
212+
it("surfaces process failures during extraction", async () => {
213+
const child = Object.assign(new EventEmitter(), {
214+
stdout: new PassThrough(),
215+
stderr: new PassThrough(),
216+
kill: vi.fn(),
217+
})
218+
mockSpawn.mockReturnValue(child as unknown as ReturnType<typeof spawn>)
219+
220+
const extraction = extractSingleBinary("/tmp/dcg.tar.xz", tempDir, DCG_ARCHIVES["linux-x64"])
221+
child.stderr.write("invalid archive")
222+
child.emit("close", 2)
223+
224+
await expect(extraction).rejects.toThrow("invalid archive")
225+
})
226+
227+
it("reuses an existing managed binary and restores its executable permissions", async () => {
228+
const binaryPath = getDcgBinaryPath(tempDir)
229+
expect(binaryPath).toBeDefined()
230+
await mkdir(path.dirname(binaryPath!), { recursive: true })
231+
await writeFile(binaryPath!, "existing binary")
232+
await writeFile(path.join(path.dirname(binaryPath!), ".dcg-version"), DCG_VERSION)
233+
if (process.platform !== "win32") {
234+
await chmod(binaryPath!, 0o600)
235+
}
236+
237+
await expect(ensureDcgInstalled(tempDir)).resolves.toBe(binaryPath)
238+
expect(mockSpawn).not.toHaveBeenCalled()
239+
if (process.platform !== "win32") {
240+
expect((await stat(binaryPath!)).mode & 0o111).toBe(0o111)
241+
}
242+
})
243+
244+
it("warns when the current platform is unsupported", async () => {
245+
const platformKey = `${process.platform}-${process.arch}`
246+
const info = DCG_ARCHIVES[platformKey]
247+
if (!info) return
248+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
249+
Reflect.deleteProperty(DCG_ARCHIVES, platformKey)
250+
251+
try {
252+
await expect(ensureDcgInstalled(tempDir)).resolves.toBeUndefined()
253+
expect(warnSpy).toHaveBeenCalledWith(`[DCG] Unsupported platform: ${platformKey}`)
254+
} finally {
255+
Reflect.set(DCG_ARCHIVES, platformKey, info)
256+
warnSpy.mockRestore()
257+
}
258+
})
259+
260+
it("downloads, verifies, extracts, and deduplicates a new installation", async () => {
261+
const info = getDcgArchiveInfo()
262+
expect(info).toBeDefined()
263+
if (!info || info.archive.endsWith(".zip")) return
264+
265+
const archive = Buffer.from("test archive")
266+
const originalChecksum = info.sha256
267+
Object.defineProperty(info, "sha256", {
268+
value: createHash("sha256").update(archive).digest("hex"),
269+
configurable: true,
270+
})
271+
const response = Object.assign(new PassThrough(), {
272+
statusCode: 200,
273+
headers: { "content-length": String(archive.length) },
274+
})
275+
const request = Object.assign(new EventEmitter(), {
276+
setTimeout: vi.fn(),
277+
destroy: vi.fn(),
278+
})
279+
mockGet.mockImplementation(
280+
(
281+
_url: string | URL,
282+
optionsOrCallback: RequestOptions | ((response: IncomingMessage) => void),
283+
optionalCallback?: (response: IncomingMessage) => void,
284+
) => {
285+
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback
286+
setImmediate(() => {
287+
// The downloader uses only the response stream/status subset supplied here.
288+
callback?.(response as unknown as IncomingMessage)
289+
response.end(archive)
290+
})
291+
// The downloader uses only timeout/error handling from ClientRequest.
292+
return request as unknown as ReturnType<typeof get>
293+
},
294+
)
295+
296+
mockSpawn.mockImplementation((executable, args) => {
297+
const child = Object.assign(new EventEmitter(), {
298+
stdout: new PassThrough(),
299+
stderr: new PassThrough(),
300+
kill: vi.fn(),
301+
})
302+
setImmediate(async () => {
303+
if (executable === "tar") {
304+
const stagingDir = args[args.indexOf("-C") + 1]
305+
await writeFile(path.join(stagingDir, info.binary), "executable")
306+
}
307+
child.emit("close", 0)
308+
})
309+
// The process runner uses only the event and stream subset supplied here.
310+
return child as unknown as ReturnType<typeof spawn>
311+
})
312+
313+
try {
314+
const firstInstallation = ensureDcgInstalled(tempDir)
315+
const concurrentInstallation = ensureDcgInstalled(tempDir)
316+
expect(concurrentInstallation).toBe(firstInstallation)
317+
318+
const binaryPath = await firstInstallation
319+
if (!binaryPath) throw new Error("Expected DCG to be supported in this test")
320+
expect(await readFile(binaryPath, "utf8")).toBe("executable")
321+
expect(mockGet).toHaveBeenCalledTimes(1)
322+
expect(mockSpawn).toHaveBeenCalledTimes(1)
323+
await expect(access(path.join(tempDir, `${DCG_VERSION}-${info.archive}`))).rejects.toThrow()
324+
expect(await readFile(path.join(tempDir, "destructive-command-guard", ".dcg-version"), "utf8")).toBe(
325+
DCG_VERSION,
326+
)
327+
} finally {
328+
Object.defineProperty(info, "sha256", { value: originalChecksum, configurable: true })
329+
}
330+
})
331+
332+
it.skipIf(!getDcgArchiveInfo()?.archive.endsWith(".zip"))(
333+
"downloads, verifies, extracts, and installs a ZIP archive",
334+
async () => {
335+
const info = getDcgArchiveInfo()
336+
if (!info) throw new Error("Expected a ZIP archive in this test")
337+
338+
const archive = Buffer.from("test ZIP archive")
339+
const originalChecksum = info.sha256
340+
Object.defineProperty(info, "sha256", {
341+
value: createHash("sha256").update(archive).digest("hex"),
342+
configurable: true,
343+
})
344+
const response = Object.assign(new PassThrough(), {
345+
statusCode: 200,
346+
headers: { "content-length": String(archive.length) },
347+
})
348+
const request = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() })
349+
mockGet.mockImplementation((_url, optionsOrCallback, optionalCallback) => {
350+
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback
351+
setImmediate(() => {
352+
callback?.(response as unknown as IncomingMessage)
353+
response.end(archive)
354+
})
355+
return request as unknown as ReturnType<typeof get>
356+
})
357+
mockSpawn.mockImplementation((_executable, args) => {
358+
const child = Object.assign(new EventEmitter(), {
359+
stdout: new PassThrough(),
360+
stderr: new PassThrough(),
361+
kill: vi.fn(),
362+
})
363+
setImmediate(async () => {
364+
const destinationIndex = args.indexOf(
365+
"$archivePath = $args[0]; $destination = $args[1]; Expand-Archive -LiteralPath $archivePath -DestinationPath $destination -Force",
366+
)
367+
const stagingDir = args[destinationIndex + 2]
368+
await writeFile(path.join(stagingDir, info.binary), "ZIP executable")
369+
child.emit("close", 0)
370+
})
371+
return child as unknown as ReturnType<typeof spawn>
372+
})
373+
374+
try {
375+
const binaryPath = await ensureDcgInstalled(tempDir)
376+
if (!binaryPath) throw new Error("Expected DCG to be supported in this test")
377+
expect(await readFile(binaryPath, "utf8")).toBe("ZIP executable")
378+
expect(await readFile(path.join(tempDir, "destructive-command-guard", ".dcg-version"), "utf8")).toBe(
379+
DCG_VERSION,
380+
)
381+
await expect(access(path.join(tempDir, `${DCG_VERSION}-${info.archive}`))).rejects.toThrow()
382+
} finally {
383+
Object.defineProperty(info, "sha256", { value: originalChecksum, configurable: true })
384+
}
385+
},
386+
)
387+
})

0 commit comments

Comments
 (0)