Files
software-mansion__argent/packages/tool-server/test/file-inputs-tar-upload.test.ts
Gabe Debes 10a78d0527 feat(file-inputs): add tar-upload kind for streaming remote file/directory uploads (#443)
## What

A new `tar-upload` file-input kind so a tool that reads a local file or
directory (e.g. `reinstall-app`'s app bundle) works when the tool-server
runs on a different machine than the caller.

## Why

`reinstall-app` assumes the bundle is on the tool-server's filesystem.
That breaks with a cloud-hosted simulator — the bundle is on your
machine, not the server's. The existing `file` kind inlines base64 and
is capped at 32 MB, too small for a real `.app`/`.apk`; `tar-upload`
streams instead.

## How

When you're not linked to a remote server, an existing local path is
used in place.

When you are (e.g. `argent link`), the client tars the path, streams it
to `POST /upload`, and the tool call carries `uploadId` plus a SHA-256
`contentHash`. The server always extracts from that upload — even if the
same path already exists on disk — verifies the hash, and vets tar
members before extracting.

The client only uploads when the path is on its own machine (a line on
stderr so you know it's working). Same command local or remote:

```
argent run reinstall-app --udid <udid> --bundleId <id> --appPath ./MyApp.app
```

## Shared extraction

Per review feedback, the tar pack/unpack logic is unified in a small
`@argent/archive` package used by **both** directions — this upload path
and the pre-existing artifact download (`streamDirectoryAsTarGz` /
`downloadAndExtractArchive`). The shared extractor rejects
path-traversal entries, symlinks whose target escapes the extract dir,
and non-file/dir members (hardlinks, devices) — while still allowing a
bundle's internal symlinks. This also hardens the artifact-download
path, which previously extracted without those checks.

## Tests

Client + server unit and HTTP round-trip tests: dir and file upload,
skip-when-not-local, co-located no-op, hash required, path-traversal /
escaping-symlink / hardlink rejection, sidecar handling, single-member
resolution, `/upload` auth + size caps, and artifact download
extraction. All suites pass.

---------

Co-authored-by: Filip131311 <contact@filipkaminski.com>
2026-07-10 12:29:48 +02:00

322 lines
11 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createHash } from "node:crypto";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { FILE_INPUT_MARKER, type FileInputSpec } from "@argent/registry";
import { resolveFileInputs, type UploadEntry } from "../src/file-inputs";
const execFileAsync = promisify(execFile);
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "tar-dir-test-"));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
function wire(overrides: Record<string, unknown>) {
return { [FILE_INPUT_MARKER]: true, ...overrides };
}
async function wireWithStat(overrides: Record<string, unknown>) {
const filePath = overrides.path as string;
const st = await fs.stat(filePath);
return wire({ ...overrides, size: st.size, mtimeMs: st.mtimeMs });
}
async function sha256File(filePath: string): Promise<string> {
const data = await fs.readFile(filePath);
return createHash("sha256").update(data).digest("hex");
}
async function uploadEntry(tarPath: string): Promise<UploadEntry> {
return { tarPath, sha256: await sha256File(tarPath) };
}
function wireUpload(clientPath: string, uploadId: string, entry: UploadEntry) {
return wire({ path: clientPath, uploadId, contentHash: entry.sha256 });
}
const TAR_UPLOAD_SPEC: FileInputSpec[] = [
{ target: "appPath", path: "${appPath}", kind: "tar-upload" },
];
async function makeFakeApp(name = "MyApp.app"): Promise<string> {
const appDir = path.join(tmpDir, name);
await fs.mkdir(appDir);
await fs.writeFile(path.join(appDir, "Info.plist"), "<plist/>");
await fs.writeFile(path.join(appDir, "MyApp"), "binary");
return appDir;
}
async function makeFakeApk(name = "app.apk"): Promise<string> {
const apkPath = path.join(tmpDir, name);
await fs.writeFile(apkPath, "apk-bytes");
return apkPath;
}
async function tarApp(source: string, extraMembers: string[] = []): Promise<string> {
const tarPath = path.join(tmpDir, "upload.tar.gz");
await execFileAsync("tar", [
"-czf",
tarPath,
"-C",
path.dirname(source),
path.basename(source),
...extraMembers,
]);
return tarPath;
}
describe("resolveFileInputs — tar-upload kind", () => {
it("resolves in place when the directory exists on this host with matching stat", async () => {
const appDir = await makeFakeApp();
const { args, fileInputs } = await resolveFileInputs(
{ fileInputs: TAR_UPLOAD_SPEC },
{ appPath: await wireWithStat({ path: appDir }) }
);
expect(args.appPath).toBe(appDir);
expect(fileInputs!.appPath).toMatchObject({ presentOnHost: true, viaUpload: false });
});
it("does not resolve in place when directory mtime does not match", async () => {
const appDir = await makeFakeApp();
const st = await fs.stat(appDir);
await expect(
resolveFileInputs(
{ fileInputs: TAR_UPLOAD_SPEC },
{ appPath: wire({ path: appDir, size: st.size, mtimeMs: 0 }) },
() => undefined
)
).rejects.toThrow(/no upload was provided/);
});
it("resolves a file in place when it exists on this host with matching stat", async () => {
const apk = await makeFakeApk();
const { args, fileInputs } = await resolveFileInputs(
{ fileInputs: TAR_UPLOAD_SPEC },
{ appPath: await wireWithStat({ path: apk }) }
);
expect(args.appPath).toBe(apk);
expect(fileInputs!.appPath).toMatchObject({ presentOnHost: true, viaUpload: false });
});
it("prefers the upload over a same-path host copy when uploadId is set", async () => {
const hostApp = await makeFakeApp("MyApp.app");
await fs.writeFile(path.join(hostApp, "MyApp"), "host-bytes");
const uploadSrc = path.join(tmpDir, "upload-src", "MyApp.app");
await fs.mkdir(path.dirname(uploadSrc), { recursive: true });
await fs.mkdir(uploadSrc);
await fs.writeFile(path.join(uploadSrc, "MyApp"), "upload-bytes");
const tarPath = await tarApp(uploadSrc);
const uploadId = "prefer-upload";
const entry = await uploadEntry(tarPath);
const { args } = await resolveFileInputs(
{ fileInputs: TAR_UPLOAD_SPEC },
{
appPath: wire({
path: hostApp,
uploadId,
contentHash: entry.sha256,
...(await (async () => {
const st = await fs.stat(hostApp);
return { size: st.size, mtimeMs: st.mtimeMs };
})()),
}),
},
(id) => (id === uploadId ? entry : undefined)
);
expect(await fs.readFile(path.join(args.appPath as string, "MyApp"), "utf8")).toBe(
"upload-bytes"
);
});
it("extracts the uploaded archive and returns the app dir path", async () => {
const appDir = await makeFakeApp("MyApp.app");
const tarPath = await tarApp(appDir);
const uploadId = "test-upload-id";
const entry = await uploadEntry(tarPath);
const { args, fileInputs, cleanup } = await resolveFileInputs(
{ fileInputs: TAR_UPLOAD_SPEC },
{ appPath: wireUpload("/client/MyApp.app", uploadId, entry) },
(id) => (id === uploadId ? entry : undefined)
);
const resolvedPath = args.appPath as string;
expect(path.basename(resolvedPath)).toBe("MyApp.app");
expect(await fs.stat(resolvedPath)).toBeTruthy();
expect(await fs.readFile(path.join(resolvedPath, "Info.plist"), "utf8")).toBe("<plist/>");
expect(fileInputs!.appPath).toMatchObject({ viaUpload: true });
await cleanup();
await expect(fs.stat(resolvedPath)).rejects.toThrow();
});
it("extracts an uploaded single file (e.g. an .apk) and returns its path", async () => {
const apk = await makeFakeApk("app.apk");
const tarPath = await tarApp(apk);
const uploadId = "test-upload-id-apk";
const entry = await uploadEntry(tarPath);
const { args, fileInputs } = await resolveFileInputs(
{ fileInputs: TAR_UPLOAD_SPEC },
{ appPath: wireUpload("/client/app.apk", uploadId, entry) },
(id) => (id === uploadId ? entry : undefined)
);
const resolvedPath = args.appPath as string;
expect(path.basename(resolvedPath)).toBe("app.apk");
expect(await fs.readFile(resolvedPath, "utf8")).toBe("apk-bytes");
expect(fileInputs!.appPath).toMatchObject({ viaUpload: true });
});
it("picks the bundle over a macOS AppleDouble sidecar in the archive", async () => {
const appDir = await makeFakeApp("MyApp.app");
await fs.writeFile(path.join(tmpDir, "._MyApp.app"), "appledouble");
const tarPath = await tarApp(appDir, ["._MyApp.app"]);
const uploadId = "test-upload-id-sidecar";
const entry = await uploadEntry(tarPath);
const { args } = await resolveFileInputs(
{ fileInputs: TAR_UPLOAD_SPEC },
{ appPath: wireUpload("/client/MyApp.app", uploadId, entry) },
(id) => (id === uploadId ? entry : undefined)
);
const resolvedPath = args.appPath as string;
expect(path.basename(resolvedPath)).toBe("MyApp.app");
expect((await fs.stat(resolvedPath)).isDirectory()).toBe(true);
});
it("removes the original tar after extraction", async () => {
const appDir = await makeFakeApp();
const tarPath = await tarApp(appDir);
const uploadId = "test-upload-id-2";
const entry = await uploadEntry(tarPath);
const { cleanup } = await resolveFileInputs(
{ fileInputs: TAR_UPLOAD_SPEC },
{ appPath: wireUpload("/client/MyApp.app", uploadId, entry) },
(id) => (id === uploadId ? entry : undefined)
);
// tar should already be removed by resolveOne after extraction
await expect(fs.stat(tarPath)).rejects.toThrow();
await cleanup();
});
it("fails with guidance when remote but no uploadId provided", async () => {
const ghost = path.join(tmpDir, "NotHere.app");
await expect(
resolveFileInputs(
{ fileInputs: TAR_UPLOAD_SPEC },
{ appPath: wire({ path: ghost }) },
() => undefined
)
).rejects.toThrow(/no upload was provided/);
});
it("fails when uploadId is set without contentHash", async () => {
const appDir = await makeFakeApp("MyApp.app");
const tarPath = await tarApp(appDir);
const uploadId = "no-hash";
const entry = await uploadEntry(tarPath);
await expect(
resolveFileInputs(
{ fileInputs: TAR_UPLOAD_SPEC },
{ appPath: wire({ path: "/client/MyApp.app", uploadId }) },
(id) => (id === uploadId ? entry : undefined)
)
).rejects.toThrow(/missing a content hash/i);
});
it("fails clearly when the uploadId is not in the registry", async () => {
await expect(
resolveFileInputs(
{ fileInputs: TAR_UPLOAD_SPEC },
{
appPath: wire({
path: "/client/MyApp.app",
uploadId: "stale-id",
contentHash: "0".repeat(64),
}),
},
() => undefined
)
).rejects.toThrow(/was not found on the tool-server/);
});
it("fails when the client content hash does not match the stored upload", async () => {
const appDir = await makeFakeApp("MyApp.app");
const tarPath = await tarApp(appDir);
const uploadId = "hash-mismatch";
const entry = await uploadEntry(tarPath);
await expect(
resolveFileInputs(
{ fileInputs: TAR_UPLOAD_SPEC },
{
appPath: wire({ path: "/client/MyApp.app", uploadId, contentHash: "0".repeat(64) }),
},
(id) => (id === uploadId ? entry : undefined)
)
).rejects.toThrow(/content hash mismatch/i);
// The upload tar must be reclaimed even though the failure is before extraction —
// the registry entry is already gone, so nothing else would clean it up.
await expect(fs.stat(tarPath)).rejects.toThrow();
});
it("refuses to extract archives with path-traversal members", async () => {
const tarPath = path.join(tmpDir, "malicious.tar.gz");
const innocent = path.join(tmpDir, "innocent.txt");
await fs.writeFile(innocent, "pwned");
// -P keeps the absolute member name (portable across GNU and bsd tar); an
// absolute path escapes the extract dir and must be refused before extraction.
await execFileAsync("tar", ["-c", "-z", "-P", "-f", tarPath, innocent]);
const uploadId = "malicious";
const entry = await uploadEntry(tarPath);
await expect(
resolveFileInputs(
{ fileInputs: TAR_UPLOAD_SPEC },
{ appPath: wireUpload("/client/MyApp.app", uploadId, entry) },
(id) => (id === uploadId ? entry : undefined)
)
).rejects.toThrow(/unsafe path/i);
});
it("removes the uploaded tar even when extraction fails", async () => {
const corruptTar = path.join(tmpDir, "corrupt.tar.gz");
await fs.writeFile(corruptTar, "not a real gzip archive");
const uploadId = "test-upload-id-corrupt";
const entry = { tarPath: corruptTar, sha256: await sha256File(corruptTar) };
await expect(
resolveFileInputs(
{ fileInputs: TAR_UPLOAD_SPEC },
{ appPath: wireUpload("/client/MyApp.app", uploadId, entry) },
(id) => (id === uploadId ? entry : undefined)
)
).rejects.toThrow();
await expect(fs.stat(corruptTar)).rejects.toThrow(); // removed despite the failure
});
});