diff --git a/src/daemon/__tests__/resumable-upload.test.ts b/src/daemon/__tests__/resumable-upload.test.ts index 73a1defe14..fbc4774946 100644 --- a/src/daemon/__tests__/resumable-upload.test.ts +++ b/src/daemon/__tests__/resumable-upload.test.ts @@ -2,6 +2,7 @@ import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import fs from 'node:fs'; +import path from 'node:path'; import { PassThrough, Readable } from 'node:stream'; import type { IncomingMessage } from 'node:http'; import { AppError } from '@agent-device/kernel/errors'; @@ -10,6 +11,8 @@ import { finalizeResumableUpload, receiveResumableUploadChunk, } from '../resumable-upload.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { runCmdSync } from '../../utils/exec.ts'; test('finalizing an unknown upload reports expiry with a recovery hint', async () => { const error = await finalizeResumableUpload('missing-upload-id').then( @@ -53,6 +56,40 @@ test('oversized ranged chunks roll back atomically and can be retried and finali } }); +test('finalize extracts a gzip-compressed app bundle upload', async () => { + const tempRoot = mkdtempForTestSync('agent-device-resumable-gzip-'); + const appDir = path.join(tempRoot, 'Sample.app'); + const archivePath = path.join(tempRoot, 'Sample.tar.gz'); + try { + fs.mkdirSync(appDir, { recursive: true }); + fs.writeFileSync(path.join(appDir, 'payload.txt'), 'payload'); + runCmdSync('tar', ['czf', archivePath, '-C', tempRoot, 'Sample.app'], { + env: { ...process.env, COPYFILE_DISABLE: '1' }, + }); + const archive = fs.readFileSync(archivePath); + const uploadId = beginResumableUpload({ + ...uploadOptions(archive), + fileName: 'Sample.app', + artifactType: 'app-bundle', + platform: 'ios', + contentType: 'application/gzip', + }).uploadId; + await receiveResumableUploadChunk({ uploadId, req: request(archive) }); + + const finalized = await finalizeResumableUpload(uploadId); + try { + assert.equal( + fs.readFileSync(path.join(finalized.artifactPath, 'payload.txt'), 'utf8'), + 'payload', + ); + } finally { + fs.rmSync(finalized.tempDir, { recursive: true, force: true }); + } + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + test('an early finalize keeps the upload resumable', async () => { const bytes = Buffer.from('resume'); const uploadId = beginUpload(bytes).uploadId; diff --git a/src/daemon/__tests__/upload.test.ts b/src/daemon/__tests__/upload.test.ts index e4c31e903c..4676e32e09 100644 --- a/src/daemon/__tests__/upload.test.ts +++ b/src/daemon/__tests__/upload.test.ts @@ -57,6 +57,39 @@ test('receiveUpload rejects app bundle archives containing symlinks', async () = } }); +test('receiveUpload extracts gzip-compressed app bundle archives', async () => { + const tempRoot = mkdtempForTestSync('agent-device-upload-gzip-'); + const appDir = path.join(tempRoot, 'Sample.app'); + const archivePath = path.join(tempRoot, 'Sample.tar.gz'); + + try { + fs.mkdirSync(appDir, { recursive: true }); + fs.writeFileSync(path.join(appDir, 'payload.txt'), 'payload'); + runCmdSync('tar', ['czf', archivePath, '-C', tempRoot, 'Sample.app'], { + env: { ...process.env, COPYFILE_DISABLE: '1' }, + }); + const archive = fs.readFileSync(archivePath); + const req = makeUploadRequest(archive, { + 'x-artifact-type': 'app-bundle', + 'x-artifact-filename': 'Sample.app', + 'content-type': 'application/gzip', + 'content-length': String(archive.length), + }); + + const upload = await receiveUpload(req); + try { + assert.equal( + fs.readFileSync(path.join(upload.artifactPath, 'payload.txt'), 'utf8'), + 'payload', + ); + } finally { + fs.rmSync(upload.tempDir, { recursive: true, force: true }); + } + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + test('streamReadableToFile removes partial files after stream errors', async () => { const tempRoot = mkdtempForTestSync('agent-device-upload-error-'); const destPath = path.join(tempRoot, 'partial.bin'); diff --git a/src/daemon/artifact-archive.ts b/src/daemon/artifact-archive.ts index 5b04b3d9d6..32774b7cba 100644 --- a/src/daemon/artifact-archive.ts +++ b/src/daemon/artifact-archive.ts @@ -12,10 +12,11 @@ export async function extractTarInstallableArtifact(params: { }): Promise { const outputRoot = path.join(params.tempDir, 'extracted'); let rootName = ''; + const type = await detectTarArchiveType(params.archivePath); await extractArchiveSafely({ archivePath: params.archivePath, outputRoot, - type: 'tar', + type, validateManifest: (manifest) => { rootName = resolveArchiveRootName(manifest, params.platform, params.expectedRootName); }, @@ -31,6 +32,19 @@ export async function extractTarInstallableArtifact(params: { return installablePath; } +async function detectTarArchiveType(archivePath: string): Promise<'tar' | 'tgz'> { + const handle = await fs.promises.open(archivePath, 'r'); + try { + const signature = Buffer.alloc(2); + const { bytesRead } = await handle.read(signature, 0, signature.length, 0); + return bytesRead === signature.length && signature[0] === 0x1f && signature[1] === 0x8b + ? 'tgz' + : 'tar'; + } finally { + await handle.close(); + } +} + function resolveArchiveRootName( manifest: readonly ArchiveManifestEntry[], platform: 'ios' | 'android',