Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/daemon/__tests__/resumable-upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(
Expand Down Expand Up @@ -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;
Expand Down
33 changes: 33 additions & 0 deletions src/daemon/__tests__/upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
16 changes: 15 additions & 1 deletion src/daemon/artifact-archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ export async function extractTarInstallableArtifact(params: {
}): Promise<string> {
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);
},
Expand All @@ -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',
Expand Down
Loading