Skip to content

[Bug]: Canonical archify.zip is not reproducible on Windows: Windows-style output paths fail and bin/archify.mjs loses its 0755 mode #384

Description

@i-so-late

Archify version or commit

2.17.0-dev.1 at main commit 1891105

Installation method

Git clone

Diagram type

Installation or packaging

Exact command

# Windows 10, Git for Windows (Git Bash / mintty), Node v22.21.1, clean checkout of main @ 1891105, repository root.
# Note: inside mintty Git for Windows aliases `node` to `winpty node.exe`; use `command node` whenever output is redirected.

# 1. Windows-style absolute output path -> fails before writing anything
mkdir -p /c/Users/<user>/AppData/Local/Temp/archify-repro
bash scripts/build-zip.sh "C:/Users/<user>/AppData/Local/Temp/archify-repro/out.zip"
echo "exit=$?"

# 2. Same directory as a POSIX path -> builds, but the bytes are not canonical
bash scripts/build-zip.sh /c/Users/<user>/AppData/Local/Temp/archify-repro/out.zip
sha256sum archify.zip /c/Users/<user>/AppData/Local/Temp/archify-repro/out.zip
/c/Windows/System32/tar.exe -tvf archify.zip | grep -a "bin/archify.mjs"
/c/Windows/System32/tar.exe -tvf /c/Users/<user>/AppData/Local/Temp/archify-repro/out.zip | grep -a "bin/archify.mjs"

# 3. The repository's own reproducibility test hits defect 1
cd archify
command node --test --test-name-pattern="byte-for-byte" test/release-package-gates.test.mjs

Minimal redacted JSON reproduction

// No typed JSON is involved. The input is the checkout itself (any tracked tree at 1891105).
// The failure is in the packaging scripts under scripts/, not in a diagram.

Validation receipt or exact error

// ---- Defect 1: Windows-style absolute output path (step 1) ----
// build-zip.sh exits 1; nothing is written. The path Node was asked to open:
Error: ENOENT: no such file or directory, open 'D:\Workspace\archify\C;C:\Program Files\Git\Users\<user>\AppData\Local\Temp\archify-repro\.out.zip.<pid>.<random>.tmp'
    at Object.openSync (node:fs:561:18)
    at file:///D:/Workspace/archify/scripts/write-deterministic-zip.mjs:156:19
exit=1

// ---- Defect 1 as seen by the repository test (step 3) ----
// The test passes an os.tmpdir() path (backslash form); this time MSYS performs no conversion and Node
// resolves the concatenated string against the current drive.
not ok 1 - archive build is byte-for-byte reproducible across caller time zones without system zip
    Error: ENOENT: no such file or directory, open 'D:\d\Workspace\archify\C:\Users\<user>\AppData\Local\Temp\archify-package-reproducible-XXXXXX\.utc.zip.<pid>.<random>.tmp'
        at file:///D:/Workspace/archify/scripts/write-deterministic-zip.mjs:156:19
    1 !== 0            // archify/test/release-package-gates.test.mjs:559  assert.equal(build.status, 0, ...)
# tests 1
# pass 0
# fail 1
# duration_ms 1127.8061

// ---- Defect 2: executable bit lost (step 2) ----
// Same size, different digest:
ff40803bb51b003ce2b7dc5af7567552c021cbc6e33a2af0e100bf97498164b8  archify.zip   (committed, 1,877,430 bytes)
45185254ccf6db627b4bba4380949860dbea8b6029739b5878e51da562733f43  out.zip       (built on Windows, 1,877,430 bytes)
// bsdtar listing of the only entry that differs:
-rwxr-xr-x  0 0  0  76056 Jan 01 1980 archify/bin/archify.mjs     // committed archify.zip
-rw-r--r--  0 0  0  76056 Jan 01 1980 archify/bin/archify.mjs     // Windows build
// Entry-by-entry comparison with Python's zipfile (script below): 79/79 entries have identical bytes and CRC;
// the only metadata difference is external_attr 0o100755 -> 0o100644 on archify/bin/archify.mjs
// (comparison script and the chmod/stat probe are in "Actual behavior" below).

Expected behavior

  1. scripts/build-zip.sh accepts a Windows-style absolute output path (C:/... or C:\...) the same way it accepts POSIX and relative paths. The repository's own test hands it an os.tmpdir() path, so this is the documented entry point on Windows.
  2. With Node 22 the rebuilt archive byte-matches the committed archify.zip on Windows as well. CONTRIBUTING.md (§Packages and generated artifacts, lines 87-90) and the comment in scripts/build-zip.sh state that canonical bytes depend only on the Node 22 toolchain. The archive should record the Git index mode that scripts/stage-clean-skill.mjs already enforces (100755 for archify/bin/archify.mjs, the only executable tracked under archify/).
  3. archive build is byte-for-byte reproducible across caller time zones without system zip passes on Windows with Node 22.

Actual behavior

Two independent defects. Every payload byte is correct; only the output-path handling and one mode bit are wrong.

Defect 1 — Windows-style absolute output paths are treated as relative.
scripts/build-zip.sh:8-10 tests [[ "$out" != /* ]] and prepends $(pwd)/ to anything that does not start with /. C:/... and C:\... both fail that test. For the forward-slash form the MSYS argument conversion then treats the embedded colon as a path-list separator and converts each half (D:\Workspace\archify\C;C:\Program Files\Git\Users\...); for the backslash form no conversion happens and Node resolves the string against the current drive (D:\d\Workspace\archify\C:\Users\...). Either way scripts/write-deterministic-zip.mjs:156 fails with ENOENT when opening its temporary file. Relative paths and /c/... paths work, which is why zip-freshness (/tmp/fresh.zip) never sees this.

Defect 2 — the executable bit is re-derived from the filesystem and lost on NTFS.
scripts/stage-clean-skill.mjs reads the Git index mode (git ls-files --stage, line 51), allows only 100644/100755 (line 120), and calls chmodSync(target, 0o755) for 100755 entries (line 258). scripts/write-deterministic-zip.mjs:111 then recomputes the mode as fs.statSync(file.absolute).mode & 0o111 ? 0o755 : 0o644. NTFS has no execute bit: after chmodSync(f, 0o755), statSync(f).mode & 0o777 is 0o666 on Windows (probe above), so the writer records 0644 for archify/bin/archify.mjs. That changes four bytes in the entry's central-directory header (line 87), which is why the size is identical while the digest differs.

Probe used for the NTFS claim (mode-demo.mjs, run with command node):

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-mode-demo-'));
const file = path.join(dir, 'archify.mjs');
fs.writeFileSync(file, '#!/usr/bin/env node\n');
fs.chmodSync(file, 0o755);
const mode = fs.statSync(file).mode;
console.log('platform', process.platform, 'node', process.version);
console.log('after chmodSync(0o755): stat mode =', (mode & 0o777).toString(8), 'exec bits set?', Boolean(mode & 0o111));
fs.rmSync(dir, { recursive: true, force: true });
platform win32 node v22.21.1
after chmodSync(0o755): stat mode = 666 exec bits set? false

Entry-by-entry comparison used for the "79/79 identical" claim:

import sys, zipfile
a, b = (zipfile.ZipFile(p) for p in sys.argv[1:3])
ia, ib = ({i.filename: i for i in z.infolist()} for z in (a, b))
assert ia.keys() == ib.keys()
for name in sorted(ia):
    x, y = ia[name], ib[name]
    same = a.read(name) == b.read(name) and x.CRC == y.CRC
    if not same or x.external_attr != y.external_attr:
        print(name, "content-identical" if same else "CONTENT DIFFERS", oct(x.external_attr >> 16), "->", oct(y.external_attr >> 16))

Why CI cannot see it. test and zip-freshness run on ubuntu-latest only (.github/workflows/ci.yml:9-10, 82-95); package smoke (windows-latest) only extracts the committed archive (152-181). Windows contributors are nevertheless expected to rebuild and commit archify.zip (CONTRIBUTING.md:87-90; review requests on #207 and #220) and currently cannot produce canonical bytes, and npm test reports the reproducibility test as failed on a clean Windows checkout.

Related work that does not cover this. #144 / #202 (line endings), #132 (invoking build-zip.sh through bash in tests), draft #349 (test-only change to the stager mode assertions; production code unchanged). Neither the writer's mode derivation nor the output-path check is addressed anywhere.

Suggested fix (I can open a PR).

  1. scripts/build-zip.sh: normalize Windows-style absolute paths before the /* check (cygpath -u when available); everything else unchanged.
  2. Hand the Git index mode from stage-clean-skill.mjs to write-deterministic-zip.mjs explicitly (for example a mode manifest written outside the staged tree) instead of re-deriving it from fs.stat; fail closed when a staged file has no recorded mode; keep the current stat behaviour when no manifest is given so older tags rebuilt by published-update-manifest (ci.yml:136-140) are unaffected.
  3. Add a platform-independent writer test (declared 100755 but 0644 on disk must produce 0755 in the central header, and the inverse) and let the existing reproducibility test run on Windows.

No archify.zip rebuild is involved: scripts/ is outside the packaged tree, so the committed archive must remain byte-identical and zip-freshness verifies that on Linux.

Supporting visual evidence

No response

Environment

Windows 10 Pro 10.0.19045; Git for Windows 2.32.0.windows.2 (Git Bash / mintty, GNU bash 4.4.23 msys); Node.js v22.21.1; npm 10.9.4. No coding client or model involved; the reproduction is the packaging scripts alone.

Final checks

  • I searched existing issues for the same problem.
  • I removed access tokens, credentials, private repository contents, and personal or customer data.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions