From 0d757b1d3e0d88157ead4035a0675e8338198b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 3 Nov 2026 15:07:59 +0100 Subject: [PATCH 1/2] fix: resolve createActZip only once the zip file is written archive.finalize() resolves when the archive stream ends, before the file stream has flushed it. Push reads the file right after, so a fast store call could upload a truncated zip. --- src/lib/utils.ts | 3 +++ test/local/lib/utils.test.ts | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index f4dcecaab..105a5a217 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,6 +4,7 @@ import { mkdir, readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join, relative, resolve } from 'node:path'; import process from 'node:process'; +import { finished } from 'node:stream/promises'; import { DurationFormatter as SapphireDurationFormatter, TimeTypes } from '@sapphire/duration'; import { Timestamp } from '@sapphire/timestamp'; @@ -497,6 +498,8 @@ export const createActZip = async (zipName: string, pathsToZip: string[], cwd: s pathsToZip.forEach((filePath) => archive.file(join(cwd, filePath), { name: filePath })); await archive.finalize(); + // The archive ends before the file stream has written all of it, and push reads the file right away. + await finished(writeStream); }; export interface LocalInput { diff --git a/test/local/lib/utils.test.ts b/test/local/lib/utils.test.ts index 719efa9d2..b48d3e4bc 100644 --- a/test/local/lib/utils.test.ts +++ b/test/local/lib/utils.test.ts @@ -1,6 +1,7 @@ -import { existsSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import AdmZip from 'adm-zip'; import axios from 'axios'; import { execWithLog } from '../../../src/lib/exec.js'; @@ -86,6 +87,18 @@ describe('Utils', () => { expect(existsSync(join(tempFolder, file))).toBeFalsy(), ); }); + + it('has the whole zip written when it resolves', async () => { + const zipName = joinPath('complete.zip'); + await createActZip(zipName, FILES, tmpPath); + + expect( + new AdmZip(readFileSync(zipName)) + .getEntries() + .map((entry) => entry.entryName) + .sort(), + ).toEqual([...FILES].sort()); + }); }); describe('downloadAndUnzip()', () => { From 1b9d6f273607c601528c43cab38d322277a8edf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Wed, 4 Nov 2026 09:53:25 +0100 Subject: [PATCH 2/2] fix: zip a file reached through a symlink as its content archiver writes a symlink as a link entry, which the platform unzips as a link to a file the upload may lack. The multi-file upload already sends the content, so what a push uploads no longer depends on its size. --- src/lib/utils.ts | 8 ++++++-- test/local/lib/utils.test.ts | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 105a5a217..5e752c848 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,5 @@ import { execSync } from 'node:child_process'; -import { createWriteStream, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { createWriteStream, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { mkdir, readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join, relative, resolve } from 'node:path'; @@ -495,7 +495,11 @@ export const createActZip = async (zipName: string, pathsToZip: string[], cwd: s }); archive.pipe(writeStream); - pathsToZip.forEach((filePath) => archive.file(join(cwd, filePath), { name: filePath })); + pathsToZip.forEach((filePath) => { + const absolutePath = join(cwd, filePath); + // The platform unzips links as links; send a linked file as its content, as the multi-file upload does. + archive.file(absolutePath, { name: filePath, stats: statSync(absolutePath) }); + }); await archive.finalize(); // The archive ends before the file stream has written all of it, and push reads the file right away. diff --git a/test/local/lib/utils.test.ts b/test/local/lib/utils.test.ts index b48d3e4bc..164d78950 100644 --- a/test/local/lib/utils.test.ts +++ b/test/local/lib/utils.test.ts @@ -1,5 +1,6 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import process from 'node:process'; import AdmZip from 'adm-zip'; import axios from 'axios'; @@ -99,6 +100,19 @@ describe('Utils', () => { .sort(), ).toEqual([...FILES].sort()); }); + + // Creating a symlink on Windows needs extra rights. + it.skipIf(process.platform === 'win32')('zips a file reached through a symlink as its content', async () => { + // A link entry would hold the link's target path, which the platform unzips as a link to a file the upload + // may not hold. + const zipName = joinPath('linked.zip'); + writeFileSync(joinPath('linked-target.json'), '{"title":"Input"}'); + symlinkSync('linked-target.json', joinPath('linked.json')); + + await createActZip(zipName, ['linked.json'], tmpPath); + + expect(new AdmZip(readFileSync(zipName)).getEntry('linked.json')?.getData().toString()).toBe('{"title":"Input"}'); + }); }); describe('downloadAndUnzip()', () => {