From 4d837c3f10f97c1e3781acd6c7142cf92dfb694f Mon Sep 17 00:00:00 2001 From: Om Singhal Date: Sun, 23 Aug 2026 20:56:41 -0400 Subject: [PATCH] fix(core): chmod the Core Tools binaries that are actually present Azure Functions Core Tools v4 packages no longer ship gozip at the root of the archive; it now sits under the in-proc6 and in-proc8 host folders. downloadCoreTools() still ran chmod on a hardcoded /gozip path, so a fresh install on Linux or macOS crashed with ENOENT immediately after the archive extracted fine, which left swa start unusable. Look up each known binary and chmod only the ones present on disk. These archives carry no Unix permission bits at all, so guarding the old root path alone would stop the crash but leave the relocated binaries non executable. The list covers the in-proc6 and in-proc8 host executables as well, matching what the official azure-functions-core-tools npm installer sets on the very same archive; the existence check also handles builds that ship no in process folders, so no architecture detection is needed. Fixes #1007 --- src/core/func-core-tools.spec.ts | 110 +++++++++++++++++++++++-------- src/core/func-core-tools.ts | 12 +++- 2 files changed, 91 insertions(+), 31 deletions(-) diff --git a/src/core/func-core-tools.spec.ts b/src/core/func-core-tools.spec.ts index 49c70a68c..7158d4f66 100644 --- a/src/core/func-core-tools.spec.ts +++ b/src/core/func-core-tools.spec.ts @@ -2,13 +2,12 @@ import "../../tests/_mocks/fetch.js"; import "../../tests/_mocks/fs.js"; import { vol } from "memfs"; -import { sep } from "node:path"; +import path, { sep } from "node:path"; import { PassThrough } from "node:stream"; import os from "node:os"; import * as fct from "./func-core-tools.js"; import * as nodeFetch from "node-fetch"; import { Response } from "node-fetch"; -//import AdmZip from "adm-zip"; import { logger } from "../core/utils/logger.js"; vi.spyOn(logger, "log").mockImplementation(() => {}); @@ -17,17 +16,19 @@ vi.spyOn(logger, "error").mockImplementation(() => {}); const fetch = vi.mocked(nodeFetch).default; -// SKIPPED: Mock the ZIP functionality -// vi.mock("adm-zip", async () => { -// const actual = await vi.importActual("adm-zip"); -// (actual.prototype as any).extractAllTo = () => { -// vol.fromJSON({ -// "/home/user/.swa/core-tools/v4/func": "", -// "/home/user/.swa/core-tools/v4/gozip": "", -// }); -// } -// return actual; -// }); +// Files the mocked ZIP extraction writes, relative to the destination folder +let extractedPackageFiles: Record = {}; + +// Mock the ZIP functionality: extracted files keep the default (non executable) +// mode, since Core Tools packages carry no Unix permission bits +vi.mock("adm-zip", () => { + class AdmZipMock { + extractAllTo(dest: string) { + vol.fromJSON(extractedPackageFiles, dest); + } + } + return { default: AdmZipMock }; +}); function mockResponse(response: any, status = 200) { fetch.mockResolvedValueOnce(new Response(JSON.stringify(response), { status })); @@ -49,6 +50,7 @@ function mockBinaryResponse(response: string, status = 200) { describe("funcCoreTools", () => { beforeEach(() => { vol.reset(); + extractedPackageFiles = {}; }); describe("fct.isCoreToolsVersionCompatible()", () => { @@ -239,9 +241,9 @@ describe("funcCoreTools", () => { } }); - // SKIPPED: Does not work because we cannot mock adm-zip right now - it.skip("should download core tools and return downloaded binary", async () => { + it("should download core tools and return downloaded binary", async () => { fct.setCachedInstalledSystemCoreToolsVersion(undefined); + extractedPackageFiles = { func: "" }; mockResponse({ tags: { @@ -288,19 +290,11 @@ describe("funcCoreTools", () => { }); describe("downloadCoreTools", () => { - beforeEach(() => { - vi.spyOn(os, "platform").mockReturnValue("linux"); - vi.spyOn(os, "homedir").mockReturnValue("/home/user"); - }); - - afterEach(() => { - fct.resetCachedInstalledSystemCoreToolsVersion(); - }); - - // SKIPPED: The test does not work because of adm-zip mocking right now. - it.skip("should throw an error if the download is corrupted", async () => { - fct.setCachedInstalledSystemCoreToolsVersion(undefined); + // Real sha2 for the "package" string sent by mockBinaryResponse() + const packageSha2 = "bc4a71180870f7945155fbb02f4b0a2e3faa2a62d6d31b7039013055ed19869a"; + const coreToolsFolder = path.join("/home/user", ".swa/core-tools", "v4"); + function mockRelease(sha2: string) { mockResponse({ tags: { v4: { release: "4.0.0" }, @@ -311,17 +305,75 @@ describe("funcCoreTools", () => { { OS: "Linux", downloadLink: "https://abc.com/d.zip", - sha2: "123", + sha2, size: "full", }, ], }, }, }); - mockBinaryResponse("package"); vol.fromNestedJSON({ ["/home/user/.swa/core-tools/"]: {} }); + } + + function getMode(...segments: string[]): number { + return vol.statSync(path.join(coreToolsFolder, ...segments)).mode & 0o777; + } + + beforeEach(() => { + vi.spyOn(os, "platform").mockReturnValue("linux"); + vi.spyOn(os, "homedir").mockReturnValue("/home/user"); + }); + + afterEach(() => { + fct.resetCachedInstalledSystemCoreToolsVersion(); + }); + + it("should throw an error if the download is corrupted", async () => { + fct.setCachedInstalledSystemCoreToolsVersion(undefined); + extractedPackageFiles = { func: "", gozip: "" }; + mockRelease("123"); + await expect(async () => await fct.downloadCoreTools(4)).rejects.toThrowError(/SHA2 mismatch/); }); + + it("should make the binaries executable when gozip sits at the package root", async () => { + extractedPackageFiles = { func: "", gozip: "" }; + mockRelease(packageSha2); + + await fct.downloadCoreTools(4); + + expect(getMode("func")).toBe(0o755); + expect(getMode("gozip")).toBe(0o755); + }); + + it("should make the binaries executable when gozip sits in the in process host folders", async () => { + extractedPackageFiles = { + func: "", + "in-proc6/func": "", + "in-proc6/gozip": "", + "in-proc8/func": "", + "in-proc8/gozip": "", + }; + mockRelease(packageSha2); + + await fct.downloadCoreTools(4); + + expect(getMode("func")).toBe(0o755); + expect(getMode("in-proc6", "func")).toBe(0o755); + expect(getMode("in-proc6", "gozip")).toBe(0o755); + expect(getMode("in-proc8", "func")).toBe(0o755); + expect(getMode("in-proc8", "gozip")).toBe(0o755); + }); + + it("should skip the in process host binaries when the package does not ship them", async () => { + extractedPackageFiles = { func: "" }; + mockRelease(packageSha2); + + await expect(fct.downloadCoreTools(4)).resolves.toBe("4.0.0"); + + expect(getMode("func")).toBe(0o755); + expect(vol.existsSync(path.join(coreToolsFolder, "in-proc6"))).toBe(false); + }); }); }); diff --git a/src/core/func-core-tools.ts b/src/core/func-core-tools.ts index a94553c69..b11f99625 100644 --- a/src/core/func-core-tools.ts +++ b/src/core/func-core-tools.ts @@ -15,6 +15,10 @@ const RELEASES_FEED_URL = "https://functionscdn.azureedge.net/public/cli-feed-v4 const DEFAULT_FUNC_BINARY = "func"; const VERSION_FILE = ".release-version"; const CORE_TOOLS_FOLDER = ".swa/core-tools"; +// Core Tools packages ship no Unix permission bits, so these have to be made executable after extraction. +// Not every package contains every binary: the in-proc6 and in-proc8 host folders appeared in 4.102.0 and are +// missing from some builds, and gozip moved out of the package root into those folders in 4.127.0. +const EXECUTABLE_BINARIES = ["func", "gozip", "in-proc6/func", "in-proc6/gozip", "in-proc8/func", "in-proc8/gozip"]; function getMajorVersion(version: string): number { return Number(version.split(".")[0]); @@ -253,8 +257,12 @@ export async function downloadCoreTools(version: number): Promise { // Fix permissions on MacOS/Linux if (os.platform() === "linux" || os.platform() === "darwin") { - fs.chmodSync(path.join(dest, "func"), 0o755); - fs.chmodSync(path.join(dest, "gozip"), 0o755); + for (const binary of EXECUTABLE_BINARIES) { + const binaryPath = path.join(dest, binary); + if (fs.existsSync(binaryPath)) { + fs.chmodSync(binaryPath, 0o755); + } + } } fs.writeFileSync(path.join(dest, VERSION_FILE), release.version);