Skip to content

Commit f2df420

Browse files
fix(mobile): verify inputs before publishing native builds
Re-fingerprint before a build is published or uploaded, so native edits during a build can never be shared under the old fingerprint. Local hits refresh the artifact's mtime so pruning drops the least recently used client, a failed download no longer leaves its partial archive, and the Worker rejects malformed upload ids and oversized or unsized completion bodies with structured errors. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 391a759 commit f2df420

3 files changed

Lines changed: 27 additions & 18 deletions

File tree

‎infra/task-cache/src/NativeClients.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ it.effect("serves a writer's multipart upload to readers with its digest", () =>
111111
name: "julius",
112112
method: "POST",
113113
body: JSON.stringify({ parts }),
114+
headers: { "content-length": String(JSON.stringify({ parts }).length) },
114115
});
115116
expect(completed.status).toBe(204);
116117

‎infra/task-cache/src/NativeClients.ts‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const routePrefix = "/projects/t3code/native-clients/";
99

1010
export class NativeClientsError extends Schema.TaggedError<NativeClientsError>()(
1111
"NativeClientsError",
12-
{ status: Schema.Number, reason: Schema.String },
12+
{ status: Schema.Number, reason: Schema.String, cause: Schema.optional(Schema.Defect()) },
1313
) {}
1414

1515
export class Bindings extends Context.Service<
@@ -49,7 +49,8 @@ const make = Effect.gen(function* () {
4949
const r2 = <A>(run: () => Promise<A>) =>
5050
Effect.tryPromise({
5151
try: run,
52-
catch: () => new NativeClientsError({ status: 503, reason: "storage_unavailable" }),
52+
catch: (cause) =>
53+
new NativeClientsError({ status: 503, reason: "storage_unavailable", cause }),
5354
});
5455

5556
const limit = Effect.fn("NativeClients.limit")(function* (writing: boolean) {
@@ -145,7 +146,11 @@ const make = Effect.gen(function* () {
145146
return Response.json({ uploadId: upload.uploadId });
146147
}
147148

148-
const upload = bindings.CLIENTS.resumeMultipartUpload(key, decodeURIComponent(uploadId!));
149+
const decodedId = yield* Effect.try({
150+
try: () => decodeURIComponent(uploadId!),
151+
catch: () => new NativeClientsError({ status: 400, reason: "invalid_upload_id" }),
152+
});
153+
const upload = bindings.CLIENTS.resumeMultipartUpload(key, decodedId);
149154
if (operation === "abort") {
150155
yield* r2(() => upload.abort());
151156
return new Response(null, { status: 204 });
@@ -158,9 +163,11 @@ const make = Effect.gen(function* () {
158163
);
159164
return Response.json({ etag: uploaded.etag });
160165
}
161-
const body = yield* decodeComplete(
162-
yield* r2(() => request.text().then((text) => text.slice(0, 64 * 1024))),
163-
).pipe(
166+
// A missing length would let text() buffer an unbounded body.
167+
const length = request.headers.get("content-length");
168+
if (length === null || !(Number(length) <= 64 * 1024))
169+
return yield* new NativeClientsError({ status: 413, reason: "parts_too_large" });
170+
const body = yield* decodeComplete(yield* r2(() => request.text())).pipe(
164171
Effect.mapError(() => new NativeClientsError({ status: 400, reason: "invalid_parts" })),
165172
);
166173
yield* r2(() => upload.complete([...body.parts]));

‎scripts/mobile-native-client.ts‎

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,6 @@ export const ensureClient = Effect.fn("ensureClient")(function* <E, R, E2, R2>(o
6969
yield* operations.installedBinary,
7070
yield* operations.readRecord,
7171
);
72-
const verifyInputs = Effect.gen(function* () {
73-
if ((yield* operations.fingerprint) !== fingerprint) {
74-
return yield* new NativeClientError({
75-
message:
76-
"Native inputs changed during verification. Run ensure again; this build was not recorded.",
77-
});
78-
}
79-
});
8072
if (status === "compatible") {
8173
return { status, installed: false, fingerprint };
8274
}
@@ -86,7 +78,6 @@ export const ensureClient = Effect.fn("ensureClient")(function* <E, R, E2, R2>(o
8678
return yield* new NativeClientError({
8779
message: "Install finished but the development client is not on the device.",
8880
});
89-
if (source === "build") yield* verifyInputs;
9081
yield* operations.saveRecord({ fingerprint, binary });
9182
return { status: "compatible" as const, installed: true, source, fingerprint };
9283
});
@@ -502,8 +493,9 @@ const download = Effect.fn("nativeClient.download")(function* (
502493
return yield* Effect.gen(function* () {
503494
if (platform === "ios") {
504495
const archive = `${staging}.tgz`;
505-
yield* response.stream.pipe(Stream.run(fs.sink(archive)));
506-
yield* command("tar", ["-xzf", archive, "-C", staging]).pipe(
496+
yield* response.stream.pipe(
497+
Stream.run(fs.sink(archive)),
498+
Effect.andThen(command("tar", ["-xzf", archive, "-C", staging])),
507499
Effect.ensuring(fs.remove(archive, { force: true }).pipe(Effect.ignore)),
508500
);
509501
} else {
@@ -600,6 +592,7 @@ const build = Effect.fn("nativeClient.build")(function* (
600592
platform: NativePlatform,
601593
abi: string | null,
602594
target: string,
595+
expected: string,
603596
) {
604597
const fs = yield* FileSystem.FileSystem;
605598
const path = yield* Path.Path;
@@ -661,6 +654,11 @@ const build = Effect.fn("nativeClient.build")(function* (
661654
path.join(staging, artifactNames.android),
662655
);
663656
}
657+
// Native edits during the build would publish this binary under a fingerprint it doesn't match.
658+
if ((yield* fingerprint(platform)) !== expected)
659+
return yield* new NativeClientError({
660+
message: "Native inputs changed during the build, so it was discarded. Run this again.",
661+
});
664662
yield* publish(staging, target);
665663
}).pipe(
666664
Effect.scoped,
@@ -715,6 +713,9 @@ export const resolveArtifact = Effect.fn("nativeClient.resolveArtifact")(functio
715713
const artifact = { directory, file: path.join(directory, artifactNames[platform]) };
716714
if (yield* fs.exists(artifact.file)) {
717715
yield* Console.error(`Using cached client ${key}.`);
716+
// Pruning keeps the most recently used, so a client another checkout is installing stays.
717+
const now = yield* Clock.currentTimeMillis;
718+
yield* fs.utimes(directory, now / 1000, now / 1000).pipe(Effect.ignore);
718719
return { ...artifact, source: "local" as const, buildMs: yield* readBuildMs(directory) };
719720
}
720721
yield* fs.makeDirectory(path.dirname(directory), { recursive: true });
@@ -737,7 +738,7 @@ export const resolveArtifact = Effect.fn("nativeClient.resolveArtifact")(functio
737738
`No cached client for ${key}. Building it once for every machine (5-15 min)${log ? `. Log: ${log}` : ""}`,
738739
);
739740
const started = yield* Clock.currentTimeMillis;
740-
yield* build(platform, abi, directory);
741+
yield* build(platform, abi, directory, fingerprint);
741742
yield* writeBuildMs(directory, (yield* Clock.currentTimeMillis) - started);
742743
yield* prune(path.dirname(directory), fingerprint);
743744
if (remote && (yield* HostProcess.Environment).T3_NATIVE_CLIENT_CACHE !== "read") {

0 commit comments

Comments
 (0)