can1357/oh-my-pi · error · ArchiveError

Invalid ZIP archive: malformed NTFS extra field

Error message

Invalid ZIP archive: malformed NTFS extra field

What it means

Thrown by parseNtfsMtime while walking the attributes inside a ZIP NTFS extra field (id 0x000a): a sub-attribute record's tag+size runs past the end of the field data. The library treats a malformed NTFS timestamp field as a hard error rather than silently ignoring the optional timestamp.

Source

Thrown at packages/utils/src/ar/zip.ts:235

	return info;
}

function filetimeToMs(bytes: Uint8Array, offset: number): number | undefined {
	let value = 0n;
	for (let index = 7; index >= 0; index--) value = (value << 8n) | BigInt(bytes[offset + index]!);
	const milliseconds = value / 10_000n - WINDOWS_EPOCH_FILETIME_MS;
	const number = Number(milliseconds);
	return Number.isSafeInteger(number) ? number : undefined;
}

function parseNtfsMtime(data: Uint8Array): number | undefined {
	if (data.byteLength < 4) return undefined;
	let offset = 4;
	while (offset + 4 <= data.byteLength) {
		const tag = readUInt16LE(data, offset);
		const size = readUInt16LE(data, offset + 2);
		const end = offset + 4 + size;
		if (end > data.byteLength) throw new ArchiveError("Invalid ZIP archive: malformed NTFS extra field");
		if (tag === 1 && size >= 8) return filetimeToMs(data, offset + 4);
		offset = end;
	}
	return undefined;
}

function parseDosMtime(time: number, date: number): number | undefined {
	if (date === 0) return undefined;
	const year = 1980 + ((date >>> 9) & 0x7f);
	const month = ((date >>> 5) & 0x0f) - 1;
	const day = date & 0x1f;
	const hour = (time >>> 11) & 0x1f;
	const minute = (time >>> 5) & 0x3f;
	const second = (time & 0x1f) * 2;
	if (month < 0 || month > 11 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) return undefined;
	const value = new Date(year, month, day, hour, minute, second).getTime();
	return Number.isFinite(value) ? value : undefined;
}

View on GitHub (pinned to 9690622007)

Solutions

  1. Test with `unzip -t`; if standard tools accept it, report the incompatibility and re-zip the files.
  2. Recreate the archive with a standard tool (Info-ZIP, 7-Zip), ideally without NTFS timestamp extras if the reader chokes on them.
  3. Repair with `zip -FF` if the source archive is corrupt.
  4. Strip the offending extra field with a zip-manipulation script before reading.

Example fix

// before: reading the problematic archive as-is
const zip = readZip(badArchive);
// after: re-zip cleanly to drop nonstandard extras
await $`unzip -q badArchive.zip -d tmp`;
await $`zip -qr clean.zip .`.cwd("tmp");
const zip = readZip("clean.zip");
Defensive patterns

Strategy: try-catch

Validate before calling

// Not cheaply pre-validable without parsing extras; at minimum confirm the archive passes a reference reader.
const probe = Bun.spawnSync(["unzip", "-t", path]);
if (probe.exitCode !== 0) throw new Error(`archive fails `unzip -t`; repair or re-zip before reading: ${path}`);

Try / catch

try {
  const zip = readZip(path);
} catch (err) {
  if (err instanceof ArchiveError && err.message.includes("malformed NTFS extra field")) {
    // writer emits broken NTFS timestamps: re-zip without extras
    await $`unzip -q ${path} -d tmp`; await $`zip -qr clean.zip .`.cwd("tmp");
    return readZip("clean.zip");
  }
  throw err;
}

Prevention

When it happens

Trigger: Calling zip info/read on an archive whose entries carry an NTFS extra field (0x000a) with a reserved 4-byte prefix plus attribute records where any record's declared size overflows the field. Triggered only when this optional extra field is present and internally inconsistent.

Common situations: Archives written by tools that emit NTFS timestamps with incorrect attribute lengths; archives corrupted in the extra-field region; hand-crafted or fuzzed zips.

Understand the failure class

Related errors


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/ed5184b879118565. Report an issue: GitHub.