Skip to content

fix(core): chmod the Core Tools binaries that are actually present - #1008

Open
om singhal (Om-singhaI) wants to merge 1 commit into
Azure:mainfrom
Om-singhaI:fix/core-tools-gozip-chmod-enoent
Open

fix(core): chmod the Core Tools binaries that are actually present#1008
om singhal (Om-singhaI) wants to merge 1 commit into
Azure:mainfrom
Om-singhaI:fix/core-tools-gozip-chmod-enoent

Conversation

@Om-singhaI

Copy link
Copy Markdown

fix(core): chmod the Core Tools binaries that are actually present

Fixes #1007

What breaks

On Linux and macOS, a fresh swa start that has to download Azure Functions Core Tools v4 dies immediately after the archive extracts successfully:

Error: ENOENT: no such file or directory, chmod '~/.swa/core-tools/v4/gozip'

downloadCoreTools() in src/core/func-core-tools.ts extracts the package, then unconditionally runs chmod on two fixed paths:

// 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);
}

The second call is the crash. Nothing catches it, so the CLI fails at startup even though the download and extraction both worked.

Why it happens

gozip was not removed from Core Tools. It moved.

I checked the shipped artifacts directly rather than going by the file listing of a local install. I replicated the selection logic in getLatestCoreToolsRelease() against the live feed (cli-feed-v4.json, keys reversed, stable 4.x only, coreTools entry matching the OS with size: "full"), then parsed each archive's ZIP central directory over HTTP range requests, so no full download was needed.

The release that selection resolves to today is 4.131.0:

  • Azure.Functions.Cli.linux-x64.4.13.0.zip, 8703 central directory entries: func present at the root, no gozip at the root, gozip present at in-proc6/gozip and in-proc8/gozip.
  • Azure.Functions.Cli.osx-x64.4.13.0.zip, 8698 central directory entries: identical in this respect.

So path.join(dest, "gozip") resolves to a path that no longer exists, while func is still where the code expects it.

Walking the stable 4.x Linux releases in the feed pins when this changed. The in-proc6 and in-proc8 folders appeared first and their gozip copies coexisted with the root copy for a long stretch; the root copy is what went away:

  • 4.0.0 through 4.101.0: gozip only at the root, no in-proc6 or in-proc8 folder at all
  • 4.102.0 through 4.126.0: gozip at the root and at in-proc6/gozip and in-proc8/gozip
  • 4.127.0 onward, including the current 4.131.0: root copy gone, only in-proc6/gozip and in-proc8/gozip

That makes 4.127.0 the first release that breaks this code path.

Why guarding the old path alone is not enough

The same central directory dump shows a second thing worth acting on: in every one of these archives, every entry has an external attribute field of 0. Counted across all 8703 Linux entries and all 8698 macOS entries, zero entries carry a non zero value.

These packages therefore ship no Unix permission bits at all. That is exactly why the explicit chmod on func exists in the first place, and it means every other binary in the package also lands without the executable bit.

Wrapping the existing path.join(dest, "gozip") call in an fs.existsSync check would stop the crash, but it would leave the real binaries non executable. That trades a loud failure for a quiet one.

Which binaries actually need the flag

The same 4.102.0 release that introduced in-proc6/gozip and in-proc8/gozip also introduced in-proc6/func and in-proc8/func. Those two are the in process host executables: each sits next to its own func.dll, func.dll.config, createdump and libhostfxr.so, which is the same self contained apphost layout as the package root where func is already chmodded today. Core Tools launches one of them for .NET in process function apps.

The authoritative reference for which files need the flag is Microsoft's own installer for the azure-functions-core-tools npm package, which unpacks the identical archive. Its lib/install.js at 4.13.0 does exactly this:

if (platform === 'linux' || platform === 'darwin') {
    fs.chmodSync(`${installPath}/func`, 0o755);

    // inproc is not packaged in the linux-arm64 builds, so skip setting permissions for that platform
    if (!(platform === 'linux' && arch === 'arm64')) {
        fs.chmodSync(`${installPath}/in-proc8/func`, 0o755);
        fs.chmodSync(`${installPath}/in-proc6/func`, 0o755);
    }
}

So the set this file has to cover is func plus the two in process hosts, and the gozip copies it already tries to handle. Note also that upstream skips the in process folders entirely on builds that do not ship them, which is the behaviour the existence check below gives us for free.

What the fix does

Enumerate the binaries that need the executable flag and chmod the ones that are actually on disk:

const EXECUTABLE_BINARIES = ["func", "gozip", "in-proc6/func", "in-proc6/gozip", "in-proc8/func", "in-proc8/gozip"];

// Fix permissions on MacOS/Linux
if (os.platform() === "linux" || os.platform() === "darwin") {
  for (const binary of EXECUTABLE_BINARIES) {
    const binaryPath = path.join(dest, binary);
    if (fs.existsSync(binaryPath)) {
      fs.chmodSync(binaryPath, 0o755);
    }
  }
}

Notes on the shape of this:

  • The root gozip entry stays in the list on purpose. Releases up to 4.126.0 still ship it there, and this code path is reached for any 4.x version the feed resolves to, so dropping it would regress those.
  • fs.existsSync guarding is the pattern already used elsewhere in this file, in getDownloadedCoreToolsVersion() and at the top of downloadCoreTools(). Here it also covers packages built without the in process folders, without needing to know the architecture.
  • EXECUTABLE_BINARIES sits with the other module constants, and uses forward slashes the same way CORE_TOOLS_FOLDER does, since both are fed through path.join.

Testing

The download path tests in src/core/func-core-tools.spec.ts were inert: the adm-zip mock was commented out, and both tests that exercise downloadCoreTools() were it.skip. The only mention of gozip in the file was inside the dead comment block.

I replaced that commented block with a working adm-zip mock. It writes a caller supplied set of files into memfs at the extraction destination using vol.fromJSON, which leaves them at memfs's default mode of 0o666, matching the real archives that carry no permission bits. That default is what makes the mode assertions meaningful rather than vacuous.

Both previously skipped tests now work and are un skipped and passing. Three new tests cover the layouts:

  • should make the binaries executable when gozip sits at the package root (the pre 4.127.0 layout)
  • should make the binaries executable when gozip sits in the in process host folders (the current layout, asserting func, both in-proc*/func and both in-proc*/gozip)
  • should skip the in process host binaries when the package does not ship them (a package with only a root func, which must still complete)

Result on the changed file:

 ✓ funcCoreTools > getCoreToolsBinary > should download core tools and return downloaded binary
 ✓ funcCoreTools > downloadCoreTools > should throw an error if the download is corrupted
 ✓ funcCoreTools > downloadCoreTools > should make the binaries executable when gozip sits at the package root
 ✓ funcCoreTools > downloadCoreTools > should make the binaries executable when gozip sits in the in process host folders
 ✓ funcCoreTools > downloadCoreTools > should skip the in process host binaries when the package does not ship them

The new tests fail without the source change

Reverting only src/core/func-core-tools.ts and keeping the tests reproduces the reported crash:

FAIL  src/core/func-core-tools.spec.ts > funcCoreTools > downloadCoreTools > should make the binaries executable when gozip sits in the in process host folders
Error: ENOENT: no such file or directory, open '/home/user/.swa/core-tools/v4/gozip'
 ❯ Volume.chmodSync node_modules/memfs/src/volume.ts:1828:10
 ❯ Module.downloadCoreTools src/core/func-core-tools.ts:257:8
    255|   if (os.platform() === "linux" || os.platform() === "darwin") {
    256|     fs.chmodSync(path.join(dest, "func"), 0o755);
    257|     fs.chmodSync(path.join(dest, "gozip"), 0o755);
       |        ^

Dropping only in-proc6/func and in-proc8/func from EXECUTABLE_BINARIES, keeping everything else, fails the same test on the mode assertion with expected 438 to be 493, that is 0o666 against 0o755. The in process host coverage is therefore load bearing on its own.

Full suite

npm test goes from 488 passed / 14 skipped to 493 passed / 12 skipped, with no change in what fails.

Two suites already fail on unmodified main in my environment, both from running Node 25 against a repo that targets Node 18 (.nvmrc), and both are untouched by this change:

  • src/cli/index.spec.ts fails to load, via jsonwebtoken to jwa to buffer-equal-constant-time, which reads SlowBuffer.prototype. SlowBuffer was removed in recent Node.
  • funcCoreTools > getCoreToolsBinary > should return the system binary if it's compatible expects isCoreToolsVersionCompatible(4, <node major>) to be true, but the table in this file caps v4 at Node 22, so Node 25 returns false.

I confirmed both by stashing the change and rerunning: identical failure counts before and after. CI covers Node 18, 20 and 22 on Linux, macOS and Windows, which is where this should get its real check.

npx tsc --noEmit is clean and npx prettier --check passes on both changed files.

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 <dest>/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 Azure#1007
@github-actions github-actions Bot added the scope: core Issues happened a the ./src/core level label Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: core Issues happened a the ./src/core level

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] downloadCoreTools fails with ENOENT: chmod 'gozip' because current Core Tools releases no longer ship gozip

1 participant