Skip to content
Closed
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
97 changes: 56 additions & 41 deletions extensions/file-search/src/binaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,15 @@ import { execFile } from "node:child_process";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { Crypto, Data, Effect, Encoding, FileSystem, Stream } from "effect";
import {
Crypto,
Data,
Effect,
Encoding,
FileSystem,
Layer,
Stream,
} from "effect";
import { FetchHttpClient, HttpClient } from "effect/unstable/http";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

Expand Down Expand Up @@ -161,6 +169,10 @@ export class InstallError extends Data.TaggedError("InstallError")<{
readonly cause?: unknown;
}> {}

class BinaryInstallError extends Data.TaggedError("BinaryInstallError")<{
readonly message: string;
}> {}

export interface BinaryEnv {
/** True when the executable runs and supports the flags this tool requires. */
readonly probe: (command: string, tool: ToolName) => Effect.Effect<boolean>;
Expand Down Expand Up @@ -237,9 +249,9 @@ export function readBoundedResponse<E, R>(
return Effect.gen(function* () {
const declaredLength = Number(response.headers["content-length"]);
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
return yield* Effect.fail(
new Error(`download exceeds the ${maxBytes}-byte size limit`),
);
return yield* new BinaryInstallError({
message: `download exceeds the ${maxBytes}-byte size limit`,
});
}

const result = yield* Stream.runFoldEffect(
Expand All @@ -249,7 +261,9 @@ export function readBoundedResponse<E, R>(
const totalBytes = accumulator.totalBytes + chunk.byteLength;
if (totalBytes > maxBytes) {
return Effect.fail(
new Error(`download exceeds the ${maxBytes}-byte size limit`),
new BinaryInstallError({
message: `download exceeds the ${maxBytes}-byte size limit`,
}),
);
}
return Effect.sync(() => {
Expand All @@ -272,9 +286,9 @@ function downloadAsset(client: HttpClient.HttpClient, initialUrl: URL) {

for (let redirects = 0; redirects <= MAX_DOWNLOAD_REDIRECTS; redirects++) {
if (url.protocol !== "https:") {
return yield* Effect.fail(
new Error(`refusing non-HTTPS download URL: ${url.href}`),
);
return yield* new BinaryInstallError({
message: `refusing non-HTTPS download URL: ${url.href}`,
});
}

const result = yield* Effect.scoped(
Expand All @@ -283,31 +297,27 @@ function downloadAsset(client: HttpClient.HttpClient, initialUrl: URL) {
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.location;
if (!location) {
return yield* Effect.fail(
new Error(`redirect from ${url.href} had no location header`),
);
return yield* new BinaryInstallError({
message: `redirect from ${url.href} had no location header`,
});
}
if (redirects === MAX_DOWNLOAD_REDIRECTS) {
return yield* Effect.fail(
new Error(
`download exceeded ${MAX_DOWNLOAD_REDIRECTS} redirects`,
),
);
return yield* new BinaryInstallError({
message: `download exceeded ${MAX_DOWNLOAD_REDIRECTS} redirects`,
});
}
if (!URL.canParse(location, url)) {
return yield* Effect.fail(
new Error(
`download returned an invalid redirect URL: ${location}`,
),
);
return yield* new BinaryInstallError({
message: `download returned an invalid redirect URL: ${location}`,
});
}
return { _tag: "Redirect" as const, url: new URL(location, url) };
}

if (response.status < 200 || response.status >= 300) {
return yield* Effect.fail(
new Error(`download failed with HTTP ${response.status}`),
);
return yield* new BinaryInstallError({
message: `download failed with HTTP ${response.status}`,
});
}
return {
_tag: "Complete" as const,
Expand All @@ -320,7 +330,9 @@ function downloadAsset(client: HttpClient.HttpClient, initialUrl: URL) {
url = result.url;
}

return yield* Effect.fail(new Error("download redirect handling failed"));
return yield* new BinaryInstallError({
message: "download redirect handling failed",
});
});
}

Expand All @@ -344,9 +356,9 @@ export const liveBinaryEnv: BinaryEnv = {
install: (asset, destination) => {
const install = Effect.gen(function* () {
if (!URL.canParse(asset.url)) {
return yield* Effect.fail(
new Error(`invalid download URL: ${asset.url}`),
);
return yield* new BinaryInstallError({
message: `invalid download URL: ${asset.url}`,
});
}

const url = new URL(asset.url);
Expand All @@ -360,11 +372,9 @@ export const liveBinaryEnv: BinaryEnv = {
const digestBytes = yield* crypto.digest("SHA-256", bytes);
const digest = Encoding.encodeHex(digestBytes);
if (digest !== asset.sha256) {
return yield* Effect.fail(
new Error(
`SHA-256 mismatch for ${asset.fileName}: expected ${asset.sha256}, received ${digest}`,
),
);
return yield* new BinaryInstallError({
message: `SHA-256 mismatch for ${asset.fileName}: expected ${asset.sha256}, received ${digest}`,
});
}

const workDir = yield* fs.makeTempDirectoryScoped({
Expand All @@ -384,9 +394,9 @@ export const liveBinaryEnv: BinaryEnv = {
}),
).pipe(Effect.timeout(60_000));
if (tarExitCode !== ChildProcessSpawner.ExitCode(0)) {
return yield* Effect.fail(
new Error(`tar failed with exit code ${tarExitCode}`),
);
return yield* new BinaryInstallError({
message: `tar failed with exit code ${tarExitCode}`,
});
}

const extracted = join(workDir, asset.archiveDir, asset.binaryName);
Expand All @@ -401,13 +411,18 @@ export const liveBinaryEnv: BinaryEnv = {
yield* fs.rename(stagedDestination, destination);
});

const installLayer = Layer.mergeAll(
NodeServices.layer,
NodeHttpClient.layerFetch.pipe(
Layer.provide(
Layer.succeed(FetchHttpClient.RequestInit)({ redirect: "manual" }),
),
),
);

return install.pipe(
Effect.scoped,
Effect.provide(NodeServices.layer),
Effect.provide(NodeHttpClient.layerFetch),
Effect.provideService(FetchHttpClient.RequestInit, {
redirect: "manual",
}),
Effect.provide(installLayer),
Effect.mapError(
(cause) =>
new InstallError({
Expand Down
21 changes: 21 additions & 0 deletions tests/extensions/file-search/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import {
FD_INTEL_DARWIN_VERSION,
InstallError,
liveBinaryEnv,
managedBinDir,
readBoundedResponse,
releaseAsset,
Expand Down Expand Up @@ -371,6 +372,7 @@ it.effect(
const declaredError = yield* Effect.flip(
readBoundedResponse(declared, 10),
);
assert.equal(declaredError._tag, "BinaryInstallError");
assert.match(declaredError.message, /size limit/);

const streamed = HttpClientResponse.fromWeb(
Expand All @@ -380,10 +382,29 @@ it.effect(
const streamedError = yield* Effect.flip(
readBoundedResponse(streamed, 5),
);
assert.equal(streamedError._tag, "BinaryInstallError");
assert.match(streamedError.message, /size limit/);
}),
);

it.effect("live installs preserve the typed cause behind InstallError", () =>
Effect.gen(function* () {
const asset = releaseAsset("fd", darwinArm);
assert.isDefined(asset);

const error = yield* Effect.flip(
liveBinaryEnv.install({ ...asset, url: "not a URL" }, "/unused/fd"),
);

assert.instanceOf(error, InstallError);
assert.equal(
(error.cause as { readonly _tag?: string })._tag,
"BinaryInstallError",
);
assert.match(error.message, /invalid download URL/);
}),
);

// --- notification policy ----------------------------------------------------

it("notifications: only fresh installs notify", () => {
Expand Down
Loading