diff --git a/docs/docs.json b/docs/docs.json index 3f48cb1663c5..f324d6d7b16b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -108,6 +108,7 @@ "expanded": true, "pages": [ "/runtime/networking/fetch", + "/runtime/networking/cloud-auth", "/runtime/http/websockets", "/runtime/networking/tcp", "/runtime/networking/udp", diff --git a/docs/runtime/networking/cloud-auth.mdx b/docs/runtime/networking/cloud-auth.mdx new file mode 100644 index 000000000000..fd9568f2f254 --- /dev/null +++ b/docs/runtime/networking/cloud-auth.mdx @@ -0,0 +1,187 @@ +--- +title: AWS & Google Cloud auth +description: Call AWS and Google Cloud APIs from Bun with the machine's own credentials — no SDK required +--- + +Bun can authenticate requests to AWS and Google Cloud the same way their CLIs and SDKs do: from environment variables, config files, SSO sessions, or the instance / container / pod the code is running on. Bun fetches credentials without blocking the JavaScript thread on the network, caches them, and refreshes ones that are in use in the background shortly before they expire. + +```ts +// SigV4-signed request with whatever credentials this machine has +const queues = await Bun.aws.fetch("https://sqs.us-east-1.amazonaws.com/?Action=ListQueues"); + +// Bearer token from Application Default Credentials +const buckets = await Bun.gcp.fetch("https://storage.googleapis.com/storage/v1/b?project=my-project"); +``` + +`Bun.aws` and `Bun.gcp` are ready-made clients using ambient credentials. For several accounts, regions or key files at once, construct more: + +```ts +const prod = new Bun.AWSClient({ profile: "prod", region: "eu-west-1" }); +const deployer = new Bun.GCPClient({ keyFile: "/secrets/deployer.json" }); +``` + +`Bun.s3`, `new S3Client()` and `fetch("s3://…")` use the AWS chain automatically when no `accessKeyId`/`secretAccessKey` is configured — see [S3 › Credentials](/runtime/s3#credentials). + +## AWS + +### Where credentials come from + +When a request needs AWS credentials and none were passed explicitly, Bun tries, in order: + +1. `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (+ `AWS_SESSION_TOKEN`) +2. The profile named by the `profile` option, `AWS_PROFILE`, or `default`, from `~/.aws/credentials` and `~/.aws/config` (or `AWS_SHARED_CREDENTIALS_FILE` / `AWS_CONFIG_FILE`). A profile may hold static keys, `role_arn` + `source_profile` / `credential_source` (STS `AssumeRole`), `web_identity_token_file`, `credential_process`, or IAM Identity Center settings (`sso_session` / `sso_*`, using the token that `aws sso login` cached). +3. `AWS_WEB_IDENTITY_TOKEN_FILE` + `AWS_ROLE_ARN` — EKS service-account roles (IRSA) +4. The container credentials endpoint — `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` or `AWS_CONTAINER_CREDENTIALS_FULL_URI` (+ `AWS_CONTAINER_AUTHORIZATION_TOKEN[_FILE]`), set by ECS, EKS Pod Identity, App Runner, … +5. EC2 instance metadata (IMDSv2). Honours `AWS_EC2_METADATA_DISABLED`, `AWS_EC2_METADATA_SERVICE_ENDPOINT[_MODE]`, `AWS_EC2_METADATA_V1_DISABLED`, `AWS_METADATA_SERVICE_TIMEOUT` and `AWS_METADATA_SERVICE_NUM_ATTEMPTS`. + +A source that isn't configured is skipped. A source that _is_ configured but fails (an expired SSO session, an STS `AccessDenied`, a `credential_process` that exits non-zero) is an error — Bun does not silently fall through to the next one. + +As in the AWS SDKs, selecting a profile (`AWS_PROFILE` or the `profile` option) takes precedence over exported `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`. Environment variables are read from `process.env` when credentials are resolved, so setting `process.env.AWS_PROFILE` at startup works. + +### `new Bun.AWSClient(options?)` / `Bun.aws` + +An `AWSClient` bundles signing defaults: `region`, `service`, `profile` or `accessKeyId`/`secretAccessKey`/`sessionToken`, and `endpoint` (a base URL for path-only requests, e.g. LocalStack). Every method accepts the same options to override them per call. `Bun.aws` is the instance with no overrides. + +```ts +const prod = new Bun.AWSClient({ profile: "prod", region: "eu-west-1" }); +prod.region; // "eu-west-1" +prod.profile; // "prod" +``` + +### `client.credentials()` + +Resolve (or return the cached) credentials. Useful for handing them to another SDK, or just to see what Bun found. + +```ts +const creds = await Bun.aws.credentials(); +// { +// accessKeyId: "ASIA…", +// secretAccessKey: "…", +// sessionToken: "…", // temporary credentials only +// expiration: 2026-01-01T…, // temporary credentials only +// region: "us-east-1", // if AWS_REGION / the profile set one +// accountId: "123456789012", // if the source reported it +// source: "sso", // "env" | "profile" | "assume-role" | "web-identity" | "process" | "sso" | "container" | "imds" | "explicit" +// } + +await Bun.aws.credentials({ refresh: true }); // ignore the cache +await new Bun.AWSClient({ profile: "prod" }).credentials(); // a specific profile +``` + +When nothing is found, the promise rejects with `code: "ERR_AWS_MISSING_CREDENTIALS"` and a message listing each source and why it was skipped. A configured source that failed rejects with `code: "ERR_AWS_CREDENTIALS"`. + +### `client.fetch(input, init?)` + +`fetch()`, with the request signed using [Signature Version 4](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv.html). `init` accepts everything `fetch` does plus the signing options below; with none, the service and region are inferred from `*.amazonaws.com` / `*.on.aws` hostnames and ambient credentials are used. + +```ts +const res = await Bun.aws.fetch("https://dynamodb.us-east-1.amazonaws.com/", { + method: "POST", + headers: { + "content-type": "application/x-amz-json-1.0", + "x-amz-target": "DynamoDB_20120810.GetItem", + }, + body: JSON.stringify({ TableName: "users", Key: { id: { S: "42" } } }), +}); + +// A path-only URL goes to the service's standard endpoint in the resolved +// region: https://sqs..amazonaws.com/?Action=ListQueues +await Bun.aws.fetch("/?Action=ListQueues", { service: "sqs" }); + +// API Gateway / Lambda function URL behind IAM auth on a custom domain +await Bun.aws.fetch("https://api.example.com/orders", { service: "execute-api", region: "eu-west-1", profile: "prod" }); +``` + +| Option | Default | +| ------------------------------------------------ | ---------------------------------------------------------------------------------------- | +| `service`, `region` | inferred from the hostname; region falls back to `AWS_REGION`, then the profile's region | +| `accessKeyId`, `secretAccessKey`, `sessionToken` | ambient credentials | +| `profile` | `AWS_PROFILE` / `default` | +| `signQuery`, `expiresIn` | `false`, `900` — put the signature in the query string | +| `unsignedPayload` | `false` — send `UNSIGNED-PAYLOAD` (S3-style services only) | + +Bun adds `Authorization`, `x-amz-date`, `x-amz-security-token` (temporary credentials) and, for S3, `x-amz-content-sha256`; setting those yourself is an error. `ReadableStream` bodies can only be signed for S3-style services (as `UNSIGNED-PAYLOAD`), since other services need the body's SHA-256 up front. + +A signature is only valid for the exact host and path it was computed for, so `redirect` defaults to `"manual"` — a `307` from S3 pointing at another region comes back as a `307` rather than being followed unsigned. Pass `redirect: "follow"` explicitly if you really want that. + +For `s3://` URLs keep using `fetch("s3://…", { s3: {...} })` or `Bun.s3`. + +### `client.presign(url, options?)` + +Returns a query-string-signed URL — typically an S3 object URL to hand to a browser. Takes the same signing options as `fetch`, plus `method` (default `"GET"`) and `expiresIn` (seconds, default `900`, max `604800`). + +```ts +const download = await Bun.aws.presign("https://my-bucket.s3.eu-west-1.amazonaws.com/report.pdf", { expiresIn: 3600 }); +const upload = await Bun.aws.presign("https://my-bucket.s3.eu-west-1.amazonaws.com/upload.bin", { method: "PUT" }); +const queue = await Bun.aws.presign("/123456789012/jobs?Action=ReceiveMessage", { service: "sqs" }); +``` + +It returns a promise because the credentials may still have to be fetched; the signing itself is local. For `s3://`-style paths and ACLs, [`S3File.presign()`](/runtime/s3#presigning-urls) is usually more convenient. + +### `client.eventStream(response)` + +AWS streaming APIs (Bedrock `InvokeModelWithResponseStream` / `ConverseStream`, Lambda response streaming, S3 Select, …) answer with `application/vnd.amazon.eventstream` frames rather than SSE. `eventStream()` turns such a response (or any `ReadableStream` / async iterable of bytes) into an async iterator of messages, verifying checksums as it goes. + +```ts +const res = await Bun.aws.fetch(`https://bedrock-runtime.us-east-1.amazonaws.com/model/${modelId}/converse-stream`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ messages: [{ role: "user", content: [{ text: "Write a haiku about Bun" }] }] }), +}); +for await (const message of Bun.aws.eventStream(res)) { + // message.event: "messageStart" | "contentBlockDelta" | "messageStop" | … + if (message.event === "contentBlockDelta") process.stdout.write(message.json().delta.text); +} +``` + +Each message has `headers`, `payload` (`Uint8Array`), `type` / `event` / `contentType` shortcuts for the `:message-type` / `:event-type` / `:content-type` headers, and `text()` / `json()`. An `exception` or `error` frame ends the iteration by throwing an `Error` whose `name` is the exception type exactly as the service sends it (Bedrock uses `throttlingException`, `validationException`, …); a non-2xx response is thrown before any message, with its `status`, `headers` and `body`. + +## Google Cloud + +### Where tokens come from + +[Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials), in order: + +1. `GOOGLE_APPLICATION_CREDENTIALS` — a service-account key file, or an `authorized_user` file +2. The file `gcloud auth application-default login` writes: `~/.config/gcloud/application_default_credentials.json` (`%APPDATA%\gcloud\…` on Windows, or under `CLOUDSDK_CONFIG`) +3. The metadata server on Compute Engine, GKE, Cloud Run, Cloud Functions, App Engine, Cloud Build … (honours `GCE_METADATA_HOST` and `NO_GCE_CHECK`) + +Service-account keys are exchanged for tokens with a self-signed RS256 JWT; user credentials use their refresh token. `external_account` (workload identity federation) files are not supported yet. + +### `new Bun.GCPClient(options?)` / `Bun.gcp` + +`Bun.gcp` uses Application Default Credentials. A `GCPClient` can instead be given a `keyFile` path or the key's `credentials` (object or JSON string), plus default `scopes` or an `audience` (making `fetch()` send ID tokens by default). + +```ts +const deployer = new Bun.GCPClient({ keyFile: "/secrets/deployer.json", scopes: ["cloud-platform"] }); +const invoker = new Bun.GCPClient({ + credentials: JSON.parse(process.env.SA_KEY!), + audience: "https://my-service-abc123.a.run.app", +}); +``` + +### `client.accessToken()` / `client.idToken()` + +```ts +const { token, expiration, source } = await Bun.gcp.accessToken(); +// source: "service-account" | "authorized-user" | "metadata" +// also: email, projectId, quotaProjectId when known + +await Bun.gcp.accessToken({ scopes: ["bigquery", "https://www.googleapis.com/auth/pubsub"] }); +// bare scope names expand to https://www.googleapis.com/auth/; the default is cloud-platform + +// OIDC identity token for calling Cloud Run / Cloud Functions / IAP +const { token: idToken } = await Bun.gcp.idToken("https://my-service-abc123.a.run.app"); +``` + +Tokens are cached per scope set / audience; pass `refresh: true` to force a new one. Failures reject with `code: "ERR_GCP_MISSING_CREDENTIALS"` (nothing configured) or `"ERR_GCP_CREDENTIALS"` (a configured source failed). + +### `client.fetch(input, init?)` (GCP) + +```ts +await Bun.gcp.fetch("https://pubsub.googleapis.com/v1/projects/my-project/topics"); +await Bun.gcp.fetch("https://storage.googleapis.com/storage/v1/b/my-bucket/o", { scopes: "devstorage.read_only" }); +await Bun.gcp.fetch("https://my-service-abc123.a.run.app/", { audience: "https://my-service-abc123.a.run.app" }); +``` + +`init` accepts everything `fetch` does plus `scopes` (access token, the default) or `audience` (ID token). Bun sets `Authorization: Bearer `, and `x-goog-user-project` when the credentials carry a `quota_project_id` (or `GOOGLE_CLOUD_QUOTA_PROJECT` is set) unless you set it yourself. diff --git a/docs/runtime/networking/fetch.mdx b/docs/runtime/networking/fetch.mdx index 709ddd5ceafe..cc269a68e563 100644 --- a/docs/runtime/networking/fetch.mdx +++ b/docs/runtime/networking/fetch.mdx @@ -291,6 +291,17 @@ Only PUT and POST methods support request bodies when using S3. For uploads, Bun See the [S3](/runtime/s3) documentation. +### AWS- and Google-authenticated requests + +`Bun.aws.fetch` and `Bun.gcp.fetch` are `fetch` with the request SigV4-signed for AWS, or carrying a Google Cloud bearer token, using the machine's ambient credentials: + +```ts +await Bun.aws.fetch("https://sqs.us-east-1.amazonaws.com/?Action=ListQueues"); +await Bun.gcp.fetch("https://storage.googleapis.com/storage/v1/b?project=my-project"); +``` + +See [AWS & Google Cloud auth](/runtime/networking/cloud-auth) for the options and where credentials come from. + #### File URLs - `file://` You can fetch local files using the `file:` protocol: diff --git a/docs/runtime/s3.mdx b/docs/runtime/s3.mdx index eef78b27a65d..321eb5573efd 100644 --- a/docs/runtime/s3.mdx +++ b/docs/runtime/s3.mdx @@ -185,7 +185,7 @@ When your production service needs to let users upload files to your server, it' To let users upload directly to S3, presign URLs for S3 files. Presigning generates a URL with a signature that lets a user upload that specific file to S3, without exposing your credentials or granting them unnecessary access to your bucket. -By default, Bun generates a `GET` URL that expires in 24 hours. +By default, Bun generates a `GET` URL that expires in 24 hours. Presigning is synchronous and never touches the network. [Ambient credentials](#ambient-credentials-profiles-sso-iam-roles) from SSO, STS, a container endpoint or instance metadata need one network round-trip first: run any S3 operation or `await Bun.aws.credentials()` at startup. Until that has happened, `presign()` throws `ERR_S3_MISSING_CREDENTIALS`. Bun refreshes credentials that are in use in the background before they expire. ```ts s3.ts icon="/icons/typescript.svg" import { s3 } from "bun"; @@ -273,7 +273,7 @@ const url = s3file.presign({ To redirect users to a presigned URL for an S3 file, pass an `S3File` instance to a `Response` object as the body. -The response redirects the user to a presigned URL for the S3 file. The redirect saves you the memory, time, and bandwidth cost of downloading the file to your server and sending it back to the user. +The response redirects the user to a presigned URL for the S3 file (so, like [`presign()`](#presigning-urls), it needs credentials that are already at hand). The redirect saves you the memory, time, and bandwidth cost of downloading the file to your server and sending it back to the user. ```ts s3.ts icon="/icons/typescript.svg" const response = new Response(s3file); @@ -467,6 +467,24 @@ For each option, if the `S3_*` environment variable is not set, Bun falls back t Bun reads these environment variables from [`.env` files](/runtime/environment-variables) or from the process environment at initialization time (Bun does not use `process.env` for this). +### Ambient credentials (profiles, SSO, IAM roles) + +When no access key is configured — neither in options nor in the environment — Bun resolves credentials the way the AWS CLI does: from `~/.aws/credentials` / `~/.aws/config` (static keys, `role_arn` + `source_profile`, `credential_process`, `aws sso login` sessions, `web_identity_token_file`), then `AWS_WEB_IDENTITY_TOKEN_FILE` (EKS), the container credentials endpoint (ECS, EKS Pod Identity), and finally EC2 instance metadata. So on AWS infrastructure with an IAM role attached, this just works: + +```ts +import { s3 } from "bun"; + +await s3.file("report.csv", { bucket: "my-bucket", region: "us-east-1" }).text(); +``` + +Pick a profile with `AWS_PROFILE` or the `profile` option (which takes precedence over `AWS_*` key variables): + +```ts +const prod = new S3Client({ profile: "prod", bucket: "my-bucket" }); +``` + +Bun resolves them on first use without blocking the JavaScript thread on the network. It caches the result and refreshes it in the background a few minutes before it expires. See [AWS & Google Cloud auth](/runtime/networking/cloud-auth) for the full order, `Bun.aws.credentials()`, and signing arbitrary AWS requests with `Bun.aws.fetch`. + Options you pass to `s3.file(credentials)`, `new Bun.S3Client(credentials)`, or any of the methods that accept credentials override these defaults. So if you use the same credentials for different buckets, you can set the credentials once in your `.env` file and pass only `bucket: "my-bucket"` to `s3.file()`. ### `S3Client` objects diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index d8c9402e2871..0e69e72f158e 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -4978,6 +4978,361 @@ declare module "bun" { */ function color(input: ColorInput, outputFormat: "number"): number | null; + /** + * AWS credentials as resolved by {@link Bun.aws.credentials}. + */ + interface AWSCredentials { + accessKeyId: string; + secretAccessKey: string; + /** Present for temporary credentials (STS, SSO, container, instance metadata). */ + sessionToken?: string; + /** When temporary credentials expire. Bun refreshes them ~5 minutes before this. */ + expiration?: Date; + /** The region configured alongside the credentials (`AWS_REGION` or the profile's `region`), if any. */ + region?: string; + /** The AWS account ID, when the source reports it. */ + accountId?: string; + /** + * Where the credentials came from. + * + * - `"env"`: `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (/ `AWS_SESSION_TOKEN`) + * - `"profile"`: static keys in `~/.aws/credentials` or `~/.aws/config` + * - `"assume-role"`: a profile with `role_arn` + `source_profile` / `credential_source` (STS `AssumeRole`) + * - `"web-identity"`: `AWS_WEB_IDENTITY_TOKEN_FILE` + `AWS_ROLE_ARN`, or a profile's `web_identity_token_file` (STS `AssumeRoleWithWebIdentity`, e.g. EKS IRSA) + * - `"process"`: a profile's `credential_process` + * - `"sso"`: an IAM Identity Center profile (`aws sso login`) + * - `"container"`: `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` / `_FULL_URI` (ECS, EKS Pod Identity, …) + * - `"imds"`: EC2 instance metadata (IMDSv2) + * - `"explicit"`: `accessKeyId` / `secretAccessKey` passed to the client or call + */ + source: "env" | "profile" | "assume-role" | "web-identity" | "process" | "sso" | "container" | "imds" | "explicit"; + } + + /** + * How to sign a request for AWS. Every field is optional: credentials + * default to the ambient ones (see {@link AWSClient.credentials}) and + * `service`/`region` are inferred from `*.amazonaws.com` hostnames. + */ + interface AWSSignOptions { + /** SigV4 signing name, e.g. `"s3"`, `"dynamodb"`, `"execute-api"`, `"lambda"`, `"bedrock"`. Inferred from the hostname when omitted. */ + service?: string; + /** e.g. `"us-east-1"`. Inferred from the hostname, then `AWS_REGION` / `AWS_DEFAULT_REGION`, then the profile's `region`. */ + region?: string; + /** Use these static credentials instead of ambient ones. Must be given together with `secretAccessKey`. */ + accessKeyId?: string; + secretAccessKey?: string; + sessionToken?: string; + /** Resolve credentials for this profile from `~/.aws/config` / `~/.aws/credentials` instead of the default chain. */ + profile?: string; + /** + * Base URL for path-only requests, e.g. `"http://localhost:4566"` for + * LocalStack. By default a path-only URL goes to + * `https://{service}.{region}.amazonaws.com`. + */ + endpoint?: string; + /** + * Sign with `UNSIGNED-PAYLOAD` instead of hashing the body. Only S3-style + * services accept this; it is implied for `ReadableStream` bodies sent to S3. + * @default false + */ + unsignedPayload?: boolean; + /** + * Put the signature in the query string (`X-Amz-Signature=…`) instead of + * the `Authorization` header. + * @default false + */ + signQuery?: boolean; + /** + * Lifetime of a query-string signature, in seconds (1 – 604800). + * @default 900 + */ + expiresIn?: number; + /** Sign as of this instant instead of now (mostly useful for tests). */ + signingDate?: Date | number | string; + } + + /** + * An AWS request signer bound to a set of defaults (credentials / profile, + * region, service, endpoint). {@link Bun.aws} is the instance with no + * overrides; make more when you talk to several accounts or regions. + * + * @example + * ```ts + * const prod = new Bun.AWSClient({ profile: "prod", region: "eu-west-1" }); + * const res = await prod.fetch("/?Action=ListQueues", { service: "sqs" }); + * ``` + */ + class AWSClient { + constructor(options?: AWSSignOptions); + + /** The configured region (option, `AWS_REGION`, or the resolved profile's), if known yet. */ + readonly region: string | undefined; + /** The profile ambient credentials come from (`"default"` unless set), or `undefined` for static keys. */ + readonly profile: string | undefined; + + /** + * `fetch()`, with the request signed using [AWS Signature Version 4](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv.html) + * so it can go straight to an AWS (or AWS-compatible) API. + * + * `init` takes everything `fetch()` does plus {@link AWSSignOptions}, + * which override this client's defaults for the one request. A path-only + * URL is sent to `endpoint`, or to the service's standard endpoint + * `https://{service}.{region}.amazonaws.com`. + * + * Adds `Authorization`, `x-amz-date`, `x-amz-security-token` (temporary + * credentials) and, for S3, `x-amz-content-sha256`. Because a signature is + * bound to the exact URL, `redirect` defaults to `"manual"`. + * + * @example + * ```ts + * const res = await Bun.aws.fetch("https://dynamodb.us-east-1.amazonaws.com/", { + * method: "POST", + * headers: { "content-type": "application/x-amz-json-1.0", "x-amz-target": "DynamoDB_20120810.ListTables" }, + * body: "{}", + * }); + * // relative to the service's endpoint in the client's region + * await Bun.aws.fetch("/?Action=ListQueues", { service: "sqs" }); + * // Lambda function URL with IAM auth + * await Bun.aws.fetch("https://abc123.lambda-url.eu-west-1.on.aws/"); + * ``` + */ + fetch(input: string | URL | Request, init?: BunFetchRequestInit & AWSSignOptions): Promise; + + /** + * Resolve this client's credentials — static keys as given, otherwise the + * same default chain as the AWS CLI and SDKs: + * + * 1. `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` + * (skipped when a profile is selected via `profile` or `AWS_PROFILE`) + * 2. The profile (`profile`, `AWS_PROFILE` or `default`) in + * `~/.aws/credentials` and `~/.aws/config` (`AWS_SHARED_CREDENTIALS_FILE` / + * `AWS_CONFIG_FILE`): static keys, `role_arn` + `source_profile` / + * `credential_source`, `web_identity_token_file`, `credential_process`, + * and IAM Identity Center (`sso_session` / `sso_*`, using the token cached by `aws sso login`) + * 3. `AWS_WEB_IDENTITY_TOKEN_FILE` + `AWS_ROLE_ARN` (EKS service-account roles) + * 4. The container credentials endpoint — `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` + * or `AWS_CONTAINER_CREDENTIALS_FULL_URI` (+ `AWS_CONTAINER_AUTHORIZATION_TOKEN[_FILE]`), + * as set by ECS, EKS Pod Identity, App Runner, … + * 5. EC2 instance metadata (IMDSv2, honouring `AWS_EC2_METADATA_DISABLED`, + * `AWS_EC2_METADATA_SERVICE_ENDPOINT[_MODE]` and `AWS_EC2_METADATA_V1_DISABLED`) + * + * A source that is not configured is skipped; a source that is configured + * but fails rejects with that error rather than falling through. + * + * Results are cached (per profile, per thread) and refreshed about five + * minutes before they expire, so calling this repeatedly is cheap. + * `Bun.s3`, `new S3Client()` and `fetch("s3://…")` use the same chain + * when no explicit keys are given. + * + * @param options.profile Resolve a different profile than the client's. + * @param options.refresh Discard cached credentials and resolve again. + */ + credentials(options?: { profile?: string; refresh?: boolean }): Promise; + + /** + * Create a presigned (query-string signed) URL for any AWS endpoint — + * most commonly an S3 object URL to hand to a browser. A path-only `url` + * is resolved against the service's endpoint in the client's region + * (needs `service`). + * + * Resolves once credentials are available; the signing itself is local. + * + * @example + * ```ts + * const url = await Bun.aws.presign("https://my-bucket.s3.eu-west-1.amazonaws.com/photo.jpg", { expiresIn: 3600 }); + * const upload = await Bun.aws.presign("https://my-bucket.s3.eu-west-1.amazonaws.com/upload.bin", { method: "PUT" }); + * ``` + */ + presign( + url: string | URL, + options?: AWSSignOptions & { + /** @default "GET" */ + method?: "GET" | "PUT" | "POST" | "DELETE" | "HEAD" | "PATCH" | (string & {}); + }, + ): Promise; + + /** + * Decode an `application/vnd.amazon.eventstream` body — the framing AWS + * uses for streaming responses (Bedrock `InvokeModelWithResponseStream` / + * `ConverseStream`, Lambda response streaming, S3 Select, …) — into its + * messages as they arrive. Checksums are verified; an `exception` or + * `error` frame is thrown as an `Error` whose `name` is the exception + * type as the service spells it (e.g. Bedrock's `throttlingException`) + * with the frame's `headers` and text `body`; a non-2xx `Response` is + * thrown with `status`, `headers` and `body`. + * + * @example + * ```ts + * const res = await Bun.aws.fetch(`https://bedrock-runtime.us-east-1.amazonaws.com/model/${modelId}/converse-stream`, { + * method: "POST", + * headers: { "content-type": "application/json" }, + * body: JSON.stringify({ messages: [{ role: "user", content: [{ text: "Hello" }] }] }), + * }); + * for await (const message of Bun.aws.eventStream(res)) { + * if (message.event === "contentBlockDelta") process.stdout.write(message.json().delta.text); + * } + * ``` + */ + eventStream( + source: + | Response + | ReadableStream + | AsyncIterable + | Blob + | ArrayBufferView + | ArrayBuffer, + ): AsyncIterableIterator; + } + + interface AWSEventStreamMessage { + /** All headers of the frame. `long` values are `bigint`s, `timestamp`s are `Date`s, `uuid`s are strings. */ + readonly headers: Record>; + readonly payload: Uint8Array; + /** The `:message-type` header: `"event"` for data frames. */ + readonly type: string | undefined; + /** The `:event-type` header (e.g. `"chunk"`, `"contentBlockDelta"`). */ + readonly event: string | undefined; + /** The `:content-type` header. */ + readonly contentType: string | undefined; + /** The payload as UTF-8 text. */ + text(): string; + /** The payload parsed as JSON. */ + json(): any; + } + + /** + * The default {@link AWSClient}: ambient credentials, region from the + * environment / profile. + * + * @example + * ```ts + * const res = await Bun.aws.fetch("https://sqs.us-east-1.amazonaws.com/?Action=ListQueues"); + * const { accessKeyId, source } = await Bun.aws.credentials(); + * ``` + */ + var aws: AWSClient; + + interface GCPClientOptions { + /** + * Path to a service-account (or `authorized_user`) key file. Defaults to + * `GOOGLE_APPLICATION_CREDENTIALS`, then gcloud's application-default file. + */ + keyFile?: string; + /** The key file's contents, as an object or JSON string, instead of a path. */ + credentials?: string | Record; + /** + * Default OAuth scopes for access tokens. Bare names expand to + * `https://www.googleapis.com/auth/`. + * @default ["https://www.googleapis.com/auth/cloud-platform"] + */ + scopes?: string | string[]; + /** Make `fetch()` send an ID token for this audience by default (Cloud Run / IAP). */ + audience?: string; + } + + interface GCPTokenOptions { + /** OAuth scopes for this token; defaults to the client's. */ + scopes?: string | string[]; + /** Discard the cached token and fetch a new one. */ + refresh?: boolean; + } + + interface GCPToken { + /** The bearer token to put in an `Authorization` header. */ + token: string; + /** When the token expires. Bun refreshes it ~4 minutes before this. */ + expiration: Date; + /** + * Where the token came from: + * - `"service-account"`: a service-account key (file, inline, or the gcloud ADC file) + * - `"authorized-user"`: user credentials from `gcloud auth application-default login` + * - `"metadata"`: the metadata server (Compute Engine, GKE, Cloud Run, Cloud Functions, App Engine, Cloud Build …) + */ + source: "service-account" | "authorized-user" | "metadata"; + /** The service account's email, when known. */ + email?: string; + /** `project_id` from the key file or metadata server, when known. */ + projectId?: string; + /** `quota_project_id` from the credentials file, or `GOOGLE_CLOUD_QUOTA_PROJECT`. */ + quotaProjectId?: string; + } + + /** + * Google Cloud tokens from a service-account key or + * [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials), + * without an SDK. {@link Bun.gcp} is the instance using ADC. + * + * @example + * ```ts + * const ci = new Bun.GCPClient({ keyFile: "/secrets/deployer.json" }); + * await ci.fetch("https://storage.googleapis.com/storage/v1/b?project=my-project"); + * ``` + */ + class GCPClient { + constructor(options?: GCPClientOptions); + + /** + * `fetch()` with `Authorization: Bearer ` (and `x-goog-user-project` + * when the credentials carry a quota project). `init` takes everything + * `fetch()` does plus `scopes` (access token) or `audience` (OIDC **ID + * token**, for Cloud Run / Cloud Functions / IAP) to override the + * client's default for one request. + * + * @example + * ```ts + * await Bun.gcp.fetch("https://storage.googleapis.com/storage/v1/b?project=my-project"); + * await Bun.gcp.fetch("https://my-service-abc123.a.run.app/api", { audience: "https://my-service-abc123.a.run.app" }); + * ``` + */ + fetch( + input: string | URL | Request, + init?: BunFetchRequestInit & (GCPTokenOptions | { audience: string; refresh?: boolean }), + ): Promise; + + /** + * Get an OAuth2 access token. With no `keyFile`/`credentials` this uses + * Application Default Credentials: + * + * 1. `GOOGLE_APPLICATION_CREDENTIALS` — a service-account key file or an + * `authorized_user` file + * 2. `gcloud auth application-default login`'s file + * (`~/.config/gcloud/application_default_credentials.json`, + * `%APPDATA%\gcloud\…` on Windows, or under `CLOUDSDK_CONFIG`) + * 3. The metadata server on Compute Engine, GKE, Cloud Run, Cloud + * Functions, App Engine … (honouring `GCE_METADATA_HOST` and `NO_GCE_CHECK`) + * + * Tokens are cached per scope set and refreshed shortly before they expire. + */ + accessToken(options?: GCPTokenOptions): Promise; + + /** + * Get an OpenID Connect **identity** token asserting this workload's + * identity to `audience` — what Cloud Run, Cloud Functions and IAP expect + * for service-to-service calls. Meant for service accounts (key or + * metadata server): with `gcloud auth application-default login` user + * credentials Google issues a token for gcloud's own client ID instead of + * `audience`, which Cloud Run and Cloud Functions accept but IAP does not. + * + * @example + * ```ts + * const { token } = await Bun.gcp.idToken("https://my-service-abc123.a.run.app"); + * ``` + */ + idToken(audience?: string): Promise; + idToken(options: { audience?: string; refresh?: boolean }): Promise; + } + + /** + * The default {@link GCPClient} (Application Default Credentials). + * + * @example + * ```ts + * const { token } = await Bun.gcp.accessToken(); + * await Bun.gcp.fetch("https://bigquery.googleapis.com/bigquery/v2/projects/my-project/datasets"); + * ``` + */ + var gcp: GCPClient; + /** * Bun.semver parses and compares version numbers. */ diff --git a/packages/bun-types/s3.d.ts b/packages/bun-types/s3.d.ts index 1d1d62ec494c..05a006438b09 100644 --- a/packages/bun-types/s3.d.ts +++ b/packages/bun-types/s3.d.ts @@ -168,9 +168,32 @@ declare module "bun" { /** * The access key ID for authentication. * Defaults to `S3_ACCESS_KEY_ID` or `AWS_ACCESS_KEY_ID` environment variables. + * + * When neither this option nor those environment variables are set, Bun + * resolves credentials the same way the AWS CLI and SDKs do — from + * `~/.aws/credentials` / `~/.aws/config` (including SSO, `credential_process`, + * `role_arn` + `source_profile` and `web_identity_token_file` profiles), + * `AWS_WEB_IDENTITY_TOKEN_FILE` (EKS), the container credentials endpoint + * (ECS / EKS Pod Identity) and finally EC2 instance metadata (IMDSv2). + * Resolved credentials are cached and refreshed shortly before they expire. + * See {@link Bun.aws.credentials}. */ accessKeyId?: string; + /** + * The profile to read from `~/.aws/credentials` / `~/.aws/config` when no + * explicit `accessKeyId`/`secretAccessKey` are given. Takes precedence over + * `AWS_ACCESS_KEY_ID`-style environment variables. + * + * Defaults to the `AWS_PROFILE` environment variable, or `"default"`. + * + * @example + * ```ts + * const client = new S3Client({ profile: "prod", bucket: "my-bucket" }); + * ``` + */ + profile?: string; + /** * The secret access key for authentication. * Defaults to `S3_SECRET_ACCESS_KEY` or `AWS_SECRET_ACCESS_KEY` environment variables. diff --git a/src/boringssl/lib.rs b/src/boringssl/lib.rs index 57c5e2ec84c6..cd08e9b6d2de 100644 --- a/src/boringssl/lib.rs +++ b/src/boringssl/lib.rs @@ -778,3 +778,95 @@ mod tests { assert_eq!(fmt(b"a\\b"), r#""a\\b""#); } } + +// ────────────────────────────────────────────────────────────────────────── +// sign_pem_rs256 — one-shot RSASSA-PKCS1-v1_5/SHA-256 with a PEM private key +// ────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SignPemError { + /// Not a PEM `PRIVATE KEY` / `RSA PRIVATE KEY` block BoringSSL can parse. + InvalidKey, + /// Parsed, but not an RSA key. + NotRsa, + /// `EVP_DigestSign*` failed. + SignFailed, +} + +impl core::fmt::Display for SignPemError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(match self { + SignPemError::InvalidKey => "private_key is not a valid PEM private key", + SignPemError::NotRsa => "private_key must be an RSA key for RS256", + SignPemError::SignFailed => "RS256 signing failed", + }) + } +} + +/// RS256 (`RSASSA-PKCS1-v1_5` over SHA-256) signature of `message` using the +/// PEM-encoded (PKCS#8 or PKCS#1) RSA private key `pem` — what a JWT bearer +/// assertion for a Google service account needs. +pub fn sign_pem_rs256(pem: &[u8], message: &[u8]) -> Result, SignPemError> { + /// `NID_rsaEncryption` / `EVP_PKEY_RSA`. + const NID_RSA_ENCRYPTION: c_int = 6; + load(); + // SAFETY: straight-line FFI over live locals; every object created is + // freed on every path before returning. + unsafe { + let bio = boring::BIO_new_mem_buf(pem.as_ptr().cast(), pem.len() as isize); + if bio.is_null() { + return Err(SignPemError::InvalidKey); + } + let pkey = boring::PEM_read_bio_PrivateKey(bio, ptr::null_mut(), None, ptr::null_mut()); + boring::BIO_free(bio); + if pkey.is_null() { + boring::ERR_clear_error(); + return Err(SignPemError::InvalidKey); + } + let result = (|| { + if boring::EVP_PKEY_id(pkey) != NID_RSA_ENCRYPTION { + return Err(SignPemError::NotRsa); + } + let mut ctx: boring::EVP_MD_CTX = bun_core::ffi::zeroed(); + boring::EVP_MD_CTX_init(&mut ctx); + let mut sig_len: usize = 0; + let ok = boring::EVP_DigestSignInit( + &raw mut ctx, + ptr::null_mut(), + boring::EVP_sha256(), + ptr::null_mut(), + pkey, + ) == 1 + && boring::EVP_DigestSign( + &raw mut ctx, + ptr::null_mut(), + &raw mut sig_len, + message.as_ptr(), + message.len(), + ) == 1; + if !ok { + boring::EVP_MD_CTX_cleanup(&raw mut ctx); + return Err(SignPemError::SignFailed); + } + let mut sig = vec![0u8; sig_len]; + let ok = boring::EVP_DigestSign( + &raw mut ctx, + sig.as_mut_ptr(), + &raw mut sig_len, + message.as_ptr(), + message.len(), + ) == 1; + boring::EVP_MD_CTX_cleanup(&raw mut ctx); + if !ok { + return Err(SignPemError::SignFailed); + } + sig.truncate(sig_len); + Ok(sig) + })(); + boring::EVP_PKEY_free(pkey); + if result.is_err() { + boring::ERR_clear_error(); + } + result + } +} diff --git a/src/boringssl_sys/boringssl.rs b/src/boringssl_sys/boringssl.rs index ed3994d474b1..22e6ae95b60c 100644 --- a/src/boringssl_sys/boringssl.rs +++ b/src/boringssl_sys/boringssl.rs @@ -1131,6 +1131,23 @@ unsafe extern "C" { u: *mut c_void, ) -> *mut EVP_PKEY; pub fn EVP_PKEY_free(pkey: *mut EVP_PKEY); + pub fn EVP_PKEY_id(pkey: *const EVP_PKEY) -> c_int; + + // ── EVP one-shot signing (used for RS256 JWTs) ─────────────────────── + pub fn EVP_DigestSignInit( + ctx: *mut EVP_MD_CTX, + pctx: *mut *mut EVP_PKEY_CTX, + type_: *const EVP_MD, + e: *mut ENGINE, + pkey: *mut EVP_PKEY, + ) -> c_int; + pub fn EVP_DigestSign( + ctx: *mut EVP_MD_CTX, + out_sig: *mut u8, + out_sig_len: *mut usize, + data: *const u8, + data_len: usize, + ) -> c_int; pub fn X509_verify_cert_error_string(err: c_long) -> *const c_char; diff --git a/src/bun_alloc/lib.rs b/src/bun_alloc/lib.rs index d99683419c75..576d6ed28439 100644 --- a/src/bun_alloc/lib.rs +++ b/src/bun_alloc/lib.rs @@ -1282,6 +1282,13 @@ pub unsafe fn secure_zero(p: *mut u8, len: usize) { core::sync::atomic::compiler_fence(core::sync::atomic::Ordering::SeqCst); } +/// [`secure_zero`] over a byte slice. +#[inline] +pub fn secure_zero_slice(s: &mut [u8]) { + // SAFETY: `s` is exclusively borrowed and valid for `s.len()` writes. + unsafe { secure_zero(s.as_mut_ptr(), s.len()) }; +} + /// Memory is typically not decommitted immediately when freed. Sensitive /// information kept in memory can be read until the OS decommits it or the /// allocator reuses it. Zero it before dropping. diff --git a/src/bun_core/util.rs b/src/bun_core/util.rs index 3908c9d40229..eca1dff1ef7a 100644 --- a/src/bun_core/util.rs +++ b/src/bun_core/util.rs @@ -3716,6 +3716,7 @@ pub fn dupe_z(bytes: &[u8]) -> *const core::ffi::c_char { pub use bun_alloc::free_sensitive_cstr as free_sensitive; /// Optimization-resistant memory zeroing — re-exported from `bun_alloc`. pub use bun_alloc::secure_zero; +pub use bun_alloc::secure_zero_slice; // ── argv ────────────────────────────────────────────────────────────────── // `bun.argv` — process argv as a slice of NUL-terminated byte strings. diff --git a/src/codegen/generate-classes.ts b/src/codegen/generate-classes.ts index 2bdc56702971..c6f07fef7ba2 100644 --- a/src/codegen/generate-classes.ts +++ b/src/codegen/generate-classes.ts @@ -241,7 +241,7 @@ function propRow( if (builtin !== undefined) { if (typeof builtin !== "string") throw new Error('"builtin" should be string'); return ` -{ "${name}"_s, static_cast(JSC::PropertyAttribute::Builtin), NoIntrinsic, { HashTableValue::BuiltinGeneratorType, ${builtin}, ${ +{ "${name}"_s, static_cast(JSC::PropertyAttribute::Builtin${extraPropertyAttributes}), NoIntrinsic, { HashTableValue::BuiltinGeneratorType, ${builtin}, ${ length || 0 } } } `.trim(); diff --git a/src/event_loop/EventLoopTimer.rs b/src/event_loop/EventLoopTimer.rs index e69dfb58dc63..a868a2789aa8 100644 --- a/src/event_loop/EventLoopTimer.rs +++ b/src/event_loop/EventLoopTimer.rs @@ -209,6 +209,8 @@ pub enum Tag { CronJob, GcRepeating, QuicEndpoint, + /// `bun_runtime::timer::CallbackTimer`. + CallbackTimer, } impl Tag { diff --git a/src/http/lib.rs b/src/http/lib.rs index 57bfb2fccb51..f393e820d4ad 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -502,7 +502,7 @@ impl<'a> HTTPClientResult<'a> { matches!(self.fail, Some(crate::Error::Timeout)) } - pub(crate) fn is_abort(&self) -> bool { + pub fn is_abort(&self) -> bool { matches!( self.fail, Some(crate::Error::Aborted | crate::Error::AbortedBeforeConnecting) @@ -732,7 +732,7 @@ impl ProxySettings { /// Returns true if the given hostname/host should bypass the proxy according /// to the supplied `no_proxy` list. Runs on the HTTP thread from a captured /// copy of the env value; see https://about.gitlab.com/blog/2021/01/27/we-need-to-talk-no-proxy/. -fn no_proxy_matches(no_proxy_text: &[u8], hostname: &[u8], host: &[u8]) -> bool { +pub fn no_proxy_matches(no_proxy_text: &[u8], hostname: &[u8], host: &[u8]) -> bool { if hostname.is_empty() { return false; } @@ -1082,6 +1082,7 @@ bun_core::comptime_string_map! { b"proxy-authorization" => (), b"cookie" => (), b"host" => (), + b"x-amz-security-token" => (), }; } @@ -1446,6 +1447,7 @@ pub(crate) fn print_request( let name = header.name(); if strings::eql_case_insensitive_ascii(name, b"authorization", true) || strings::eql_case_insensitive_ascii(name, b"proxy-authorization", true) + || strings::eql_case_insensitive_ascii(name, b"x-amz-security-token", true) { let value = header.value(); let scheme_len = strings::index_of_char_usize(value, b' ').map_or(0, |i| i + 1); diff --git a/src/install/repository.rs b/src/install/repository.rs index 7d86db59cd51..0a2833d1e694 100644 --- a/src/install/repository.rs +++ b/src/install/repository.rs @@ -375,6 +375,7 @@ fn exec(env: &bun_dotenv::Map, argv: &[&[u8]]) -> Result, Error> { let result = bun_spawn::run(bun_spawn::RunOptions { argv, env_map: std_map.get(), + windows_verbatim_arguments: false, })?; match result.term { diff --git a/src/js/builtins/AwsEventStream.ts b/src/js/builtins/AwsEventStream.ts new file mode 100644 index 000000000000..1e97301ca59e --- /dev/null +++ b/src/js/builtins/AwsEventStream.ts @@ -0,0 +1,4 @@ +// `AWSClient.prototype.eventStream`. Stateless, so any receiver works. +export function eventStream(this: unknown, source: unknown) { + return require("internal/aws/eventstream")(source); +} diff --git a/src/js/internal/aws/eventstream.ts b/src/js/internal/aws/eventstream.ts new file mode 100644 index 000000000000..c575202604a5 --- /dev/null +++ b/src/js/internal/aws/eventstream.ts @@ -0,0 +1,332 @@ +// Decoder for `application/vnd.amazon.eventstream` — the framing AWS uses +// for streaming responses (Bedrock InvokeModelWithResponseStream / +// ConverseStream, S3 Select, Transcribe, Lambda response streaming …). +// +// Frame layout (all integers big-endian): +// u32 total_length | u32 headers_length | u32 prelude_crc32 +// headers … | payload … | u32 message_crc32 +// A header is: u8 name_length | name | u8 value_type | value. + +const { isAnyArrayBuffer, isUint8Array } = require("node:util/types"); + +type HeaderValue = boolean | number | bigint | string | Uint8Array | Date; +type Headers = Record; + +const PRELUDE_LENGTH = 12; +const MIN_MESSAGE_LENGTH = 16; +// The service-side limits are 128 KiB of headers and 16 MiB of payload; +// anything larger is a framing error, not something to buffer. +const MAX_MESSAGE_LENGTH = 24 * 1024 * 1024; +// How much of a non-2xx body to read for the error message. +const MAX_ERROR_BODY = 64 * 1024; + +const strictDecoder = new TextDecoder("utf-8", { fatal: true }); +const textDecoder = new TextDecoder("utf-8"); +const crc32 = Bun.hash.crc32; +const Uint8ArraySubarray = Uint8Array.prototype.subarray; +const Uint8ArrayToHex = Uint8Array.prototype.toHex; + +class AWSEventStreamMessage { + headers: Headers; + payload: Uint8Array; + + constructor(headers: Headers, payload: Uint8Array) { + this.headers = headers; + this.payload = payload; + } + + /** `:message-type` (`"event"`; exception/error frames are thrown, not yielded). */ + get type(): string | undefined { + const v = this.headers[":message-type"]; + return typeof v === "string" ? v : undefined; + } + + /** `:event-type`. */ + get event(): string | undefined { + const v = this.headers[":event-type"]; + return typeof v === "string" ? v : undefined; + } + + get contentType(): string | undefined { + const v = this.headers[":content-type"]; + return typeof v === "string" ? v : undefined; + } + + text(): string { + return textDecoder.decode(this.payload); + } + + json(): unknown { + return JSON.parse(textDecoder.decode(this.payload)); + } +} + +function framingError(message: string): Error { + return $ERR_AWS_EVENT_STREAM(`Invalid AWS event stream: ${message}`); +} + +function hex(bytes: Uint8Array, from: number, to: number): string { + return Uint8ArrayToHex.$call(Uint8ArraySubarray.$call(bytes, from, to)); +} + +function utf8(bytes: Uint8Array, from: number, to: number): string { + try { + return strictDecoder.decode(Uint8ArraySubarray.$call(bytes, from, to)); + } catch { + throw framingError("header is not valid UTF-8"); + } +} + +/** `bytes` is a buffer this module owns (one frame), so views into it are fine to hand out. */ +function parseHeaders(bytes: Uint8Array, view: DataView, start: number, end: number): Headers { + const headers: Headers = Object.create(null); + let i = start; + while (i < end) { + const nameLength = bytes[i++]; + if (i + nameLength + 1 > end) throw framingError("truncated header"); + const name = utf8(bytes, i, i + nameLength); + i += nameLength; + const type = bytes[i++]; + let value: HeaderValue; + switch (type) { + case 0: + value = true; + break; + case 1: + value = false; + break; + case 2: + if (i + 1 > end) throw framingError("truncated header"); + value = view.getInt8(i); + i += 1; + break; + case 3: + if (i + 2 > end) throw framingError("truncated header"); + value = view.getInt16(i); + i += 2; + break; + case 4: + if (i + 4 > end) throw framingError("truncated header"); + value = view.getInt32(i); + i += 4; + break; + case 5: + if (i + 8 > end) throw framingError("truncated header"); + value = view.getBigInt64(i); + i += 8; + break; + case 6: + case 7: { + if (i + 2 > end) throw framingError("truncated header"); + const length = view.getUint16(i); + i += 2; + if (i + length > end) throw framingError("truncated header"); + value = type === 6 ? Uint8ArraySubarray.$call(bytes, i, i + length) : utf8(bytes, i, i + length); + i += length; + break; + } + case 8: + if (i + 8 > end) throw framingError("truncated header"); + value = new Date(Number(view.getBigInt64(i))); + i += 8; + break; + case 9: + if (i + 16 > end) throw framingError("truncated header"); + value = `${hex(bytes, i, i + 4)}-${hex(bytes, i + 4, i + 6)}-${hex(bytes, i + 6, i + 8)}-${hex(bytes, i + 8, i + 10)}-${hex(bytes, i + 10, i + 16)}`; + i += 16; + break; + default: + throw framingError(`unknown header value type ${type}`); + } + headers[name] = value; + } + return headers; +} + +/** Parse one whole frame; `bytes` is exactly the frame and owned by us. */ +function parseMessage(bytes: Uint8Array, headersLength: number): AWSEventStreamMessage { + const totalLength = bytes.length; + const view = new DataView(bytes.buffer, bytes.byteOffset, totalLength); + const expected = view.getUint32(totalLength - 4); + if (crc32(Uint8ArraySubarray.$call(bytes, 0, totalLength - 4)) !== expected) { + throw framingError("message checksum mismatch"); + } + const headers = parseHeaders(bytes, view, PRELUDE_LENGTH, PRELUDE_LENGTH + headersLength); + const payload = Uint8ArraySubarray.$call(bytes, PRELUDE_LENGTH + headersLength, totalLength - 4); + return new AWSEventStreamMessage(headers, payload); +} + +/** An `exception` / `error` frame becomes a thrown Error, like the SDKs do: + * `name` is the service's exception type as sent (casing varies by service). */ +function errorFromMessage(message: AWSEventStreamMessage): Error | undefined { + const type = message.type; + if (type === "exception") { + let detail: string | undefined; + try { + const body = message.json() as any; + detail = body?.message ?? body?.Message; + } catch {} + if (typeof detail !== "string") detail = message.text(); + const name = String(message.headers[":exception-type"] ?? "Exception"); + const err = $ERR_AWS_EVENT_STREAM_EXCEPTION(detail || name) as Error & { headers: Headers; body: string }; + err.name = name; + err.headers = message.headers; + err.body = message.text(); + return err; + } + if (type === "error") { + const name = String(message.headers[":error-code"] ?? "Error"); + const text = message.headers[":error-message"]; + const err = $ERR_AWS_EVENT_STREAM_ERROR(typeof text === "string" && text ? text : name) as Error & { + headers: Headers; + }; + err.name = name; + err.headers = message.headers; + return err; + } + return undefined; +} + +function toBytes(chunk: unknown): Uint8Array { + if (isUint8Array(chunk)) return chunk as Uint8Array; + if (isAnyArrayBuffer(chunk)) return new Uint8Array(chunk as ArrayBuffer); + if (ArrayBuffer.isView(chunk)) return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength); + throw $ERR_INVALID_ARG_TYPE("chunk", ["ArrayBuffer", "ArrayBufferView"], chunk); +} + +/** A non-2xx response carries a plain (JSON) error document, not frames. */ +async function responseError(response: Response): Promise { + let body = ""; + const stream = response.bodyUsed ? null : response.body; + if (stream && !stream.locked) { + const reader = stream.getReader(); + const parts: Uint8Array[] = []; + let size = 0; + try { + while (size < MAX_ERROR_BODY) { + const { value, done } = await reader.read(); + if (done) break; + const bytes = toBytes(value); + parts.push(bytes); + size += bytes.length; + } + } catch {} + reader.cancel().catch(() => {}); + body = textDecoder.decode(Buffer.concat(parts).subarray(0, MAX_ERROR_BODY)); + } + let detail = body; + try { + const parsed = JSON.parse(body); + if (typeof (parsed?.message ?? parsed?.Message) === "string") detail = parsed.message ?? parsed.Message; + } catch {} + if (detail.length > 512) detail = detail.slice(0, 512) + "…"; + const err = $ERR_AWS_EVENT_STREAM_RESPONSE(`HTTP ${response.status}${detail ? `: ${detail}` : ""}`) as Error & { + status: number; + headers: globalThis.Headers; + body: string; + }; + const type = response.headers.get("x-amzn-errortype"); + if (type) err.name = type.split(":")[0] || err.name; + err.status = response.status; + err.headers = response.headers; + err.body = body; + return err; +} + +type Source = { response: Response } | { chunks: AsyncIterable | Iterable }; + +/** Argument errors are the caller's, so they throw from `eventStream()` itself. */ +function classify(source: unknown): Source { + if (source instanceof Response) return { response: source }; + if (source instanceof Blob) return { chunks: source.stream() as AsyncIterable }; + if (isAnyArrayBuffer(source) || ArrayBuffer.isView(source)) return { chunks: [source] }; + if (source && typeof source === "object" && Symbol.asyncIterator in source) { + return { chunks: source as AsyncIterable }; + } + throw $ERR_INVALID_ARG_TYPE( + "source", + ["Response", "ReadableStream", "AsyncIterable", "Blob", "ArrayBuffer", "ArrayBufferView"], + source, + ); +} + +async function chunksOf(source: Source): Promise | Iterable> { + if ("chunks" in source) return source.chunks; + const { response } = source; + if (!response.ok) throw await responseError(response); + if (response.bodyUsed) throw $ERR_AWS_EVENT_STREAM_RESPONSE("the response body was already consumed"); + return (response.body as AsyncIterable | null) ?? []; +} + +async function* run(source: Source): AsyncGenerator { + // Incoming bytes are copied straight into the frame they belong to: first + // the 12-byte prelude, then, once that says how long the frame is, a + // buffer of exactly that size. A chunk is fully consumed before anything + // is yielded, so a producer may recycle it as soon as we suspend. + const prelude = new Uint8Array(PRELUDE_LENGTH); + const preludeView = new DataView(prelude.buffer); + let preludeFilled = 0; + let frame: Uint8Array | undefined; + let frameFilled = 0; + let headersLength = 0; + const ready: AWSEventStreamMessage[] = []; + let failure: Error | undefined; + + for await (const raw of await chunksOf(source)) { + const chunk = toBytes(raw); + let pos = 0; + try { + while (pos < chunk.length) { + if (frame === undefined) { + const n = Math.min(PRELUDE_LENGTH - preludeFilled, chunk.length - pos); + prelude.set(Uint8ArraySubarray.$call(chunk, pos, pos + n), preludeFilled); + preludeFilled += n; + pos += n; + if (preludeFilled < PRELUDE_LENGTH) break; + const totalLength = preludeView.getUint32(0); + headersLength = preludeView.getUint32(4); + if (crc32(Uint8ArraySubarray.$call(prelude, 0, 8)) !== preludeView.getUint32(8)) { + throw framingError("prelude checksum mismatch"); + } + if ( + totalLength < MIN_MESSAGE_LENGTH || + totalLength > MAX_MESSAGE_LENGTH || + headersLength > totalLength - MIN_MESSAGE_LENGTH + ) { + throw framingError(`bad frame lengths (${totalLength}, ${headersLength})`); + } + frame = new Uint8Array(totalLength); + frame.set(prelude); + frameFilled = PRELUDE_LENGTH; + preludeFilled = 0; + } + const n = Math.min(frame.length - frameFilled, chunk.length - pos); + frame.set(Uint8ArraySubarray.$call(chunk, pos, pos + n), frameFilled); + frameFilled += n; + pos += n; + if (frameFilled < frame.length) break; + const complete = frame; + frame = undefined; + ready.push(parseMessage(complete, headersLength)); + } + } catch (e) { + // Deliver what was decoded before the bad frame, then report it. + failure = e as Error; + } + for (let i = 0; i < ready.length; i++) { + const message = ready[i]; + const error = errorFromMessage(message); + if (error) throw error; + yield message; + } + ready.length = 0; + if (failure) throw failure; + } + if (frame !== undefined || preludeFilled > 0) throw framingError("stream ended in the middle of a message"); +} + +function decode(source: unknown): AsyncGenerator { + return run(classify(source)); +} + +export default decode; diff --git a/src/jsc/bindings/BunObject+exports.h b/src/jsc/bindings/BunObject+exports.h index 31319ae85641..b20e98f0808f 100644 --- a/src/jsc/bindings/BunObject+exports.h +++ b/src/jsc/bindings/BunObject+exports.h @@ -3,11 +3,13 @@ // --- Getters --- #define FOR_EACH_GETTER(macro) \ + macro(AWSClient) \ macro(Archive) \ macro(CSRF) \ macro(CryptoHasher) \ macro(FFI) \ macro(FileSystemRouter) \ + macro(GCPClient) \ macro(Glob) \ macro(Image) \ macro(JSON5) \ @@ -30,10 +32,12 @@ macro(ValkeyClient) \ macro(argv) \ macro(assetPrefix) \ + macro(aws) \ macro(cron) \ macro(cwd) \ macro(embeddedFiles) \ macro(enableANSIColors) \ + macro(gcp) \ macro(hash) \ macro(inspect) \ macro(isStandaloneExecutable) \ diff --git a/src/jsc/bindings/BunObject.cpp b/src/jsc/bindings/BunObject.cpp index 04c1c4ca808b..dc933ec16f28 100644 --- a/src/jsc/bindings/BunObject.cpp +++ b/src/jsc/bindings/BunObject.cpp @@ -923,6 +923,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj /* Source for BunObject.lut.h @begin bunObjectTable $ constructBunShell DontDelete|PropertyCallback + AWSClient BunObject_lazyPropCb_wrap_AWSClient DontDelete|PropertyCallback Archive BunObject_lazyPropCb_wrap_Archive DontDelete|PropertyCallback ArrayBufferSink BunObject_lazyPropCb_wrap_ArrayBufferSink DontDelete|PropertyCallback Cookie constructCookieObject DontDelete|ReadOnly|PropertyCallback @@ -930,6 +931,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj CryptoHasher BunObject_lazyPropCb_wrap_CryptoHasher DontDelete|PropertyCallback FFI BunObject_lazyPropCb_wrap_FFI DontDelete|PropertyCallback FileSystemRouter BunObject_lazyPropCb_wrap_FileSystemRouter DontDelete|PropertyCallback + GCPClient BunObject_lazyPropCb_wrap_GCPClient DontDelete|PropertyCallback Glob BunObject_lazyPropCb_wrap_Glob DontDelete|PropertyCallback Image BunObject_lazyPropCb_wrap_Image DontDelete|PropertyCallback MD4 BunObject_lazyPropCb_wrap_MD4 DontDelete|PropertyCallback @@ -957,6 +959,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj build BunObject_callback_build DontDelete|Function 1 concatArrayBuffers functionConcatTypedArrays DontDelete|Function 3 connect BunObject_callback_connect DontDelete|Function 1 + aws BunObject_lazyPropCb_wrap_aws DontDelete|PropertyCallback cron BunObject_lazyPropCb_wrap_cron DontDelete|PropertyCallback cwd BunObject_lazyPropCb_wrap_cwd DontEnum|DontDelete|PropertyCallback color BunObject_callback_color DontDelete|Function 2 @@ -971,6 +974,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj file BunObject_callback_file DontDelete|Function 1 fileURLToPath functionFileURLToPath DontDelete|Function 1 gc Generated::BunObject::jsGc DontDelete|Function 1 + gcp BunObject_lazyPropCb_wrap_gcp DontDelete|PropertyCallback generateHeapSnapshot functionGenerateHeapSnapshot DontDelete|Function 2 gunzipSync BunObject_callback_gunzipSync DontDelete|Function 1 gzipSync BunObject_callback_gzipSync DontDelete|Function 1 diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index d1ce4e83dc24..c0c9844e6795 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -219,6 +219,10 @@ const errors: ErrorCodeMapping = [ ["ERR_MYSQL_LIFETIME_TIMEOUT", Error, "MySQLError"], ["ERR_UNHANDLED_REJECTION", Error, "UnhandledPromiseRejection"], ["ERR_REQUIRE_ASYNC_MODULE", Error], + ["ERR_AWS_EVENT_STREAM", Error], + ["ERR_AWS_EVENT_STREAM_ERROR", Error], + ["ERR_AWS_EVENT_STREAM_EXCEPTION", Error], + ["ERR_AWS_EVENT_STREAM_RESPONSE", Error], ["ERR_S3_INVALID_ENDPOINT", Error], ["ERR_S3_INVALID_METHOD", Error], ["ERR_S3_INVALID_PATH", Error], diff --git a/src/jsc/job.rs b/src/jsc/job.rs index 6428045e1ab6..ed68c205777d 100644 --- a/src/jsc/job.rs +++ b/src/jsc/job.rs @@ -119,6 +119,12 @@ unsafe impl JsAffine for JsPtr {} // SAFETY: see the group note above. unsafe impl JsAffine for Protected {} +/// A completion closure for a job's JS half: whatever it captured was +/// captured on the JS thread, and `Job` only runs or drops it there. +pub struct JsCallback(pub Box JsResult<()>>); +// SAFETY: see the group note above. +unsafe impl JsAffine for JsCallback {} + /// A GC-protected value a job's completion needs (Node: a `Global` on /// the req_wrap). Unprotected on drop. pub struct Protected(crate::JSValue); @@ -223,8 +229,26 @@ pub trait JobContext: Sized + 'static { unsafe fn cancel(off: *mut Self::OffThread) { let _ = off; } + + /// The safe form of [`cancel`](Self::cancel): a closure over whatever + /// shared handles (atomics, `Arc`s) the off-thread half was built with, + /// taken on the JS thread at [`Job::schedule`] — so it never aliases the + /// job — and called by the VM's stop phase. Only consulted when + /// [`CANCELLABLE`](Self::CANCELLABLE). + fn canceller(off: &Self::OffThread) -> Option { + let _ = off; + None + } + + /// Whether a pending job keeps the event loop (and so the process) + /// running. Background refresh work says no: if nothing else is going + /// on, the process may exit without it. + const HOLDS_EVENT_LOOP: bool = true; } +/// See [`JobContext::canceller`]. +pub type Canceller = Box; + /// The type-erased head of every [`Job`] (one task tag serves every `C`), /// linked into its VM's [`JobList`] while the job is live. #[repr(C)] @@ -232,6 +256,7 @@ pub struct JobHeader { complete: unsafe fn(*mut JobHeader, &JsThread<'_>) -> JsResult<()>, release_unrun: unsafe fn(*mut JobHeader), cancel: unsafe fn(*mut JobHeader), + canceller: Option, prev: *mut JobHeader, next: *mut JobHeader, } @@ -276,6 +301,9 @@ impl JobList { // SAFETY: linked ⇒ live (jobs unlink, on this thread, before they // are freed); `cancel` neither frees nor unlinks. unsafe { + if let Some(c) = &(*job).canceller { + c(); + } ((*job).cancel)(job); job = (*job).next; } @@ -313,9 +341,17 @@ impl Job { #[track_caller] pub fn schedule(cx: &JsThread<'_>, off: C::OffThread, js: C::Js) { let mut keep_alive = KeepAlive::default(); - keep_alive.ref_(bun_io::js_vm_ctx()); + if C::HOLDS_EVENT_LOOP { + keep_alive.ref_(bun_io::js_vm_ctx()); + } + let canceller = if C::CANCELLABLE { + C::canceller(&off) + } else { + None + }; let job = bun_core::heap::into_raw(Box::new(Self { header: JobHeader { + canceller, // SAFETY: (this and the entry below) the erased dispatchers are // only reached through this header, so `p` is this `Job`. complete: |p, cx| unsafe { Self::complete(p.cast::(), cx) }, diff --git a/src/jsc/rare_data.rs b/src/jsc/rare_data.rs index 482f7698d542..3977462db55b 100644 --- a/src/jsc/rare_data.rs +++ b/src/jsc/rare_data.rs @@ -279,6 +279,10 @@ pub struct RareData { // practice). Hosting it in the consumer crate removes the upward // `s3_signing → jsc` hook. pub s3_default_client: Strong, + /// `bun_runtime::webcore::cloud::PerVm` — cached AWS/GCP credentials and the + /// resolutions in flight for this VM. Type-erased (higher tier); dropped + /// with the VM. + pub cloud_credentials: Option>, /// Per-VM, like Node's quic `BindingData` (node/src/quic/bindingdata.h). pub node_quic_callbacks: Strong, pub(crate) default_csrf_secret: Box<[u8]>, @@ -333,6 +337,7 @@ impl Default for RareData { pipe_read_scratch: Box::new(bun_event_loop::PipeReadScratch::new()), h2_padded_frame_buffer: None, compression_scratch: None, + cloud_credentials: None, s3_default_client: Strong::empty(), node_quic_callbacks: Strong::empty(), default_csrf_secret: Box::default(), @@ -1078,7 +1083,7 @@ impl Drop for RareData { fn drop(&mut self) { // pipe_read_scratch / h2_padded_frame_buffer / spawn_sync_event_loop_ / // s3_default_client / default_csrf_secret / cleanup_hooks / cron_jobs / - // path_buf / tls_default_ciphers: + // path_buf / tls_default_ciphers / cloud_credentials (disarms its timers): // all dropped automatically via field Drop. if let Some(engine) = self.boring_ssl_engine.take() { diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index 199d2448adc8..abdea0c611ce 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -346,6 +346,10 @@ pub mod bun_object { BunObject_lazyPropCb_YAML => super::get_yaml_object, BunObject_lazyPropCb_Transpiler => super::get_transpiler_constructor, BunObject_lazyPropCb_argv => super::get_argv, + BunObject_lazyPropCb_aws => super::get_aws_object, + BunObject_lazyPropCb_AWSClient => super::get_aws_client_constructor, + BunObject_lazyPropCb_gcp => super::get_gcp_object, + BunObject_lazyPropCb_GCPClient => super::get_gcp_client_constructor, BunObject_lazyPropCb_cron => super::get_cron_object, BunObject_lazyPropCb_cwd => super::get_cwd, BunObject_lazyPropCb_embeddedFiles => super::get_embedded_files, @@ -1849,20 +1853,10 @@ fn get_s3_default_client(global_this: &JSGlobalObject, _: &JSObject) -> JsResult use bun_jsc::StrongOptional; // SAFETY: bun_vm() returns the live thread-local VM for a Bun-owned global. let vm = global_this.bun_vm().as_mut(); - // NOTE: reshaped for borrowck — capture the raw env loader pointer - // before `rare_data()` takes the long-lived `&mut` of `vm`. - let env_ptr = vm.transpiler.env; - let rare = vm.rare_data(); - if let Some(v) = rare.s3_default_client.get() { + if let Some(v) = vm.rare_data().s3_default_client.get() { return Ok(v); } - // NOTE (layering): `bun_dotenv::Loader::get_s3_credentials` returns the - // T2 POD mirror; lift it into the refcounted `bun_s3_signing::S3Credentials` - // here at the high-tier call site (dotenv ≤T2 may not name s3_signing T5). - // SAFETY: `transpiler.env` is the process-lifetime dotenv loader; disjoint - // from `rare_data` storage. - let env_creds = - crate::webcore::fetch::s3_credentials_from_env(unsafe { (*env_ptr).get_s3_credentials() }); + let env_creds = crate::webcore::fetch::s3_credentials_from_env(global_this); let aws_options = match crate::webcore::s3::credentials_jsc::get_credentials_with_options( &env_creds, Default::default(), @@ -1886,7 +1880,7 @@ fn get_s3_default_client(global_this: &JSGlobalObject, _: &JSObject) -> JsResult }; let js_client = ::to_js(client, global_this); js_client.ensure_still_alive(); - rare.s3_default_client = StrongOptional::create(js_client, global_this); + vm.rare_data().s3_default_client = StrongOptional::create(js_client, global_this); Ok(js_client) } @@ -1996,6 +1990,26 @@ fn get_embedded_files(global_this: &JSGlobalObject, _: &JSObject) -> JsResult JsResult { + use crate::webcore::cloud::aws::AWSClient; + let client = AWSClient::default(global_this)?; + Ok(::to_js(*client, global_this)) +} + +fn get_aws_client_constructor(global_this: &JSGlobalObject, _: &JSObject) -> JSValue { + jsc::codegen::js::get_constructor::(global_this) +} + +fn get_gcp_object(global_this: &JSGlobalObject, _: &JSObject) -> JsResult { + use crate::webcore::cloud::gcp::GCPClient; + let client = GCPClient::default(global_this)?; + Ok(::to_js(*client, global_this)) +} + +fn get_gcp_client_constructor(global_this: &JSGlobalObject, _: &JSObject) -> JSValue { + jsc::codegen::js::get_constructor::(global_this) +} + fn get_semver(global_this: &JSGlobalObject, _: &JSObject) -> JSValue { bun_semver_jsc::SemverObject::create(global_this) } diff --git a/src/runtime/api/CloudClients.classes.ts b/src/runtime/api/CloudClients.classes.ts new file mode 100644 index 000000000000..44c37fbfd01c --- /dev/null +++ b/src/runtime/api/CloudClients.classes.ts @@ -0,0 +1,58 @@ +import { define } from "../../codegen/class-definitions"; + +export default [ + define({ + name: "AWSClient", + construct: true, + finalize: true, + configurable: false, + klass: {}, + JSType: "0b11101110", + proto: { + fetch: { + fn: "fetch", + length: 2, + }, + presign: { + fn: "presign", + length: 2, + }, + credentials: { + fn: "credentials", + length: 1, + }, + eventStream: { + builtin: "awsEventStreamEventStreamCodeGenerator", + length: 1, + }, + region: { + getter: "getRegion", + }, + profile: { + getter: "getProfile", + }, + }, + }), + define({ + name: "GCPClient", + construct: true, + finalize: true, + configurable: false, + klass: {}, + JSType: "0b11101110", + proto: { + fetch: { + fn: "fetch", + length: 2, + }, + accessToken: { + fn: "accessToken", + length: 1, + }, + idToken: { + fn: "idToken", + length: 1, + }, + }, + }), +]; diff --git a/src/runtime/dispatch.rs b/src/runtime/dispatch.rs index 66e2c554129a..9740e06e4234 100644 --- a/src/runtime/dispatch.rs +++ b/src/runtime/dispatch.rs @@ -1148,6 +1148,13 @@ pub(crate) unsafe fn __bun_fire_timer( crate::node::quic::QuicEndpoint::on_timer_fire(c); Ok(()) } + EventLoopTimerTag::CallbackTimer => { + let c: *mut crate::timer::CallbackTimer = + owner!(crate::timer::CallbackTimer, event_loop_timer); + // SAFETY: per fn contract. + unsafe { crate::timer::CallbackTimer::fire(c) }; + Ok(()) + } }; fired } diff --git a/src/runtime/timer/mod.rs b/src/runtime/timer/mod.rs index b61ea9c78d9e..f552683e97da 100644 --- a/src/runtime/timer/mod.rs +++ b/src/runtime/timer/mod.rs @@ -376,6 +376,59 @@ pub(crate) use crate::test_runner::timers::fake_timers::FakeTimers; // need `VirtualMachine.timer: All` (currently `()` in bun_jsc). Struct shape // is real so `All` embeds them by value with the correct layout. +/// A one-shot timer that calls a plain Rust function on the JS thread. Boxed +/// so the intrusive node keeps its address; does not keep the process alive. +pub struct CallbackTimer { + pub(crate) event_loop_timer: EventLoopTimer, + on_fire: fn(usize), + data: usize, +} + +bun_event_loop::impl_timer_owner!(CallbackTimer; from_timer_ptr => event_loop_timer); + +impl CallbackTimer { + pub fn new(on_fire: fn(usize), data: usize) -> Box { + Box::new(Self { + event_loop_timer: EventLoopTimer::init_paused(EventLoopTimerTag::CallbackTimer), + on_fire, + data, + }) + } + + /// (Re)arm to fire once, `ms` from now. + pub fn schedule(&mut self, ms: u64) { + let next = Timespec::ms_from_now( + TimespecMockMode::ForceRealTime, + i64::try_from(ms).unwrap_or(i64::MAX), + ); + crate::jsc_hooks::timer_all_mut().update(&raw mut self.event_loop_timer, &next); + } + + pub fn cancel(&mut self) { + if self.event_loop_timer.state == EventLoopTimerState::ACTIVE { + crate::jsc_hooks::timer_all_mut().remove(&raw mut self.event_loop_timer); + } + } + + /// # Safety + /// `this` is live (its owner cancels it on drop). + pub(crate) unsafe fn fire(this: *mut Self) { + // SAFETY: fn contract. Copy the callback out so nothing borrows + // `*this` while it runs (it may re-`schedule` or drop this timer). + let (on_fire, data) = unsafe { + (*this).event_loop_timer.state = EventLoopTimerState::FIRED; + ((*this).on_fire, (*this).data) + }; + on_fire(data); + } +} + +impl Drop for CallbackTimer { + fn drop(&mut self) { + self.cancel(); + } +} + pub struct DateHeaderTimer { pub(crate) event_loop_timer: EventLoopTimer, } diff --git a/src/runtime/webcore.rs b/src/runtime/webcore.rs index 271109c2f172..be2098abfd1e 100644 --- a/src/runtime/webcore.rs +++ b/src/runtime/webcore.rs @@ -346,6 +346,10 @@ pub mod s3 { pub use multipart::MultiPartUpload; } +#[path = "webcore/cloud/mod.rs"] +pub mod cloud; +pub use cloud::aws; + #[path = "webcore/streams.rs"] pub mod streams; diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 87d303166b86..9bda371845bf 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -3581,14 +3581,7 @@ impl BlobExt for Blob { if check_s3 { if let PathOrFileDescriptor::Path(p) = &*path_or_fd { if p.slice().starts_with(b"s3://") { - // SAFETY: bun_vm() is live for the duration of a host call. - let vm = global_this.bun_vm().as_mut(); - // `bun_dotenv::Loader` (T2) returns its local POD mirror by - // reference; lift it into the refcounted - // `bun_s3_signing::S3Credentials` here at the T6 call site - // (dotenv cannot name the s3_signing type — upward dep). - let env_creds = vm.transpiler.env_mut().get_s3_credentials(); - let credentials = crate::webcore::fetch::s3_credentials_from_env(env_creds); + let credentials = crate::webcore::fetch::s3_credentials_from_env(global_this); let copy = core::mem::replace( path_or_fd, PathOrFileDescriptor::Path(crate::webcore::node_types::PathLike::String( diff --git a/src/runtime/webcore/Response.rs b/src/runtime/webcore/Response.rs index c80be4ab57f6..4257d4a8ed61 100644 --- a/src/runtime/webcore/Response.rs +++ b/src/runtime/webcore/Response.rs @@ -1254,6 +1254,11 @@ impl Response { let s3 = blob.store.get().as_ref().unwrap().data.as_s3(); let credentials = s3.get_credentials(); + crate::webcore::s3::client::resolve_ambient_credentials_or_throw( + credentials, + global_this, + Some(s3.path()), + )?; let result = match credentials.sign_request::( &bun_s3_signing::SignOptions { diff --git a/src/runtime/webcore/S3Client.rs b/src/runtime/webcore/S3Client.rs index 201418b0b61d..a1b3880e2d85 100644 --- a/src/runtime/webcore/S3Client.rs +++ b/src/runtime/webcore/S3Client.rs @@ -189,6 +189,32 @@ where writer.write_str("\n")?; } + if !credentials.has_static_credentials() + && let Some(provider) = &credentials.provider + { + // Ambient credentials: show which chain/profile and, once resolved, the source. + formatter.write_indent(writer)?; + writer.write_str(pfmt!("credentials: \"", ENABLE_ANSI_COLORS))?; + match provider.cached() { + Some(resolved) => bun_core::write_pretty!( + writer, + ENABLE_ANSI_COLORS, + "{} (profile: {}, accessKeyId: {}…)\"", + resolved.source.as_str(), + BStr::new(provider.label()), + BStr::new(&resolved.access_key_id[..resolved.access_key_id.len().min(4)]), + )?, + None => bun_core::write_pretty!( + writer, + ENABLE_ANSI_COLORS, + "auto (profile: {}, not yet resolved)\"", + BStr::new(provider.label()), + )?, + } + formatter.print_comma::(writer)?; + writer.write_str("\n")?; + } + if let Some(acl_value) = acl { formatter.write_indent(writer)?; writer.write_str(pfmt!("acl: \"", ENABLE_ANSI_COLORS))?; @@ -276,17 +302,7 @@ impl S3Client { // SAFETY: `bun_vm()` returns the live VM pointer for `global`. let vm = global.bun_vm(); let mut args = bun_jsc::call_frame::ArgumentsSlice::init(vm, callframe.arguments()); - // `Transpiler::env_mut` is the safe accessor for the process-singleton - // dotenv loader (set during init). `get_s3_credentials` takes `&mut self` - // only to lazily memoize — single-threaded JS event-loop discipline applies. - let env_creds = crate::webcore::fetch::s3_credentials_from_env( - global - .bun_vm() - .as_mut() - .transpiler - .env_mut() - .get_s3_credentials(), - ); + let env_creds = crate::webcore::fetch::s3_credentials_from_env(global); let aws_options = ::get_credentials_with_options( &env_creds, MultiPartUploadOptions::default(), @@ -646,16 +662,7 @@ impl S3Client { let object_keys = args[0]; let options = opt_js(args[1]); - // get credentials from env — `Transpiler::env_mut` is the safe accessor - // for the process-singleton dotenv loader (set during init). - let existing_credentials = crate::webcore::fetch::s3_credentials_from_env( - global - .bun_vm() - .as_mut() - .transpiler - .env_mut() - .get_s3_credentials(), - ); + let existing_credentials = crate::webcore::fetch::s3_credentials_from_env(global); // `defer blob.detach()` — handled by Drop of `Option` field. let blob = S3File::construct_s3_file_with_s3_credentials( diff --git a/src/runtime/webcore/S3File.rs b/src/runtime/webcore/S3File.rs index 11826e19a9b3..a7138545c186 100644 --- a/src/runtime/webcore/S3File.rs +++ b/src/runtime/webcore/S3File.rs @@ -239,16 +239,7 @@ fn construct_s3_file_internal_store( path: PathLike, options: Option, ) -> JsResult { - // get credentials from env — `Transpiler::env_mut` is the safe accessor - // for the process-singleton dotenv loader (set during init). - let existing_credentials = crate::webcore::fetch::s3_credentials_from_env( - global - .bun_vm() - .as_mut() - .transpiler - .env_mut() - .get_s3_credentials(), - ); + let existing_credentials = crate::webcore::fetch::s3_credentials_from_env(global); construct_s3_file_with_s3_credentials(global, path, options, &existing_credentials) } @@ -604,6 +595,12 @@ pub(crate) fn get_presign_url_from( } let path = s3.path(); + s3::resolve_ambient_credentials_or_throw( + &credentials_with_options.credentials, + global, + Some(path), + )?; + let result = match credentials_with_options.credentials.sign_request::( &bun_s3_signing::SignOptions { path, diff --git a/src/runtime/webcore/cloud/aws/chain.rs b/src/runtime/webcore/cloud/aws/chain.rs new file mode 100644 index 000000000000..df8a20d81483 --- /dev/null +++ b/src/runtime/webcore/cloud/aws/chain.rs @@ -0,0 +1,1637 @@ +//! The AWS default credential provider chain: +//! +//! 1. environment (`AWS_ACCESS_KEY_ID` …) +//! 2. the shared config/credentials files for the selected profile — static +//! keys, `role_arn` + `source_profile`/`credential_source` (STS +//! AssumeRole), `web_identity_token_file`, `credential_process`, SSO +//! 3. `AWS_WEB_IDENTITY_TOKEN_FILE` + `AWS_ROLE_ARN` (EKS IRSA) +//! 4. container credentials (`AWS_CONTAINER_CREDENTIALS_{RELATIVE,FULL}_URI` +//! — ECS, EKS Pod Identity, and anything else speaking that protocol) +//! 5. EC2 instance metadata (IMDSv2, v1 fallback) +//! +//! A source that is *not configured* is skipped with a note; a source that +//! is configured but *fails* stops the chain with that error, so a broken +//! IRSA/SSO setup does not silently fall through to the node's instance role. +//! +//! Written as straight-line `async` code; every network round-trip and the +//! `credential_process` spawn go through [`Io`], which `provider.rs` drives +//! from the JS thread without blocking it. File reads (a few small dotfiles) +//! are done inline. + +use std::io::Write as _; + +use bstr::BStr; +use bun_core::strings; +use bun_http::Method; +use bun_s3_signing::sigv4; +use bun_s3_signing::{AwsCredentials, CredentialsSource, ProviderError}; +use bun_sys::{Fd, File}; + +use super::config::ChainConfig; +use super::ini::{IniFile, Profile, SectionKind}; +use crate::webcore::cloud::form_encode; +use crate::webcore::cloud::io::{ + ChainFuture, HttpError, HttpRequest, HttpResponse, Io, SpawnRequest, +}; +use crate::webcore::cloud::json; +use crate::webcore::s3::xml_response; + +type Outcome = Result, ProviderError>; +pub type ChainResult = Result; + +const MAX_PROFILE_DEPTH: usize = 8; +const STS_TIMEOUT_MS: u32 = 30_000; + +pub fn resolve(cfg: ChainConfig, io: Io) -> ChainFuture { + Box::pin(async move { + let mut r = Resolver { + cfg, + io, + config: None, + credentials: None, + notes: Vec::new(), + unreadable_files: Vec::new(), + }; + let c = r.run().await?; + if let Some(exp) = c.expiration + && exp <= now_secs() + AwsCredentials::EXPIRY_MARGIN_SECONDS + { + let at = sigv4::amz_datetime(exp); + return Err(err( + "ERR_AWS_CREDENTIALS", + format_args!( + "credentials from {} were already expired when they arrived (Expiration {}); check this machine's clock", + c.source.as_str(), + BStr::new(&at) + ), + )); + } + Ok(c) + }) +} + +struct Resolver { + cfg: ChainConfig, + io: Io, + config: Option, + credentials: Option, + /// Why each skipped source was skipped, for the final "nothing found" error. + notes: Vec, + /// Shared config files that exist but could not be read (`path (errno)`). + unreadable_files: Vec, +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn err(code: &'static str, args: core::fmt::Arguments<'_>) -> ProviderError { + let mut v = Vec::new(); + let _ = v.write_fmt(args); + ProviderError::new(code, v) +} + +macro_rules! fail { + ($($arg:tt)*) => { err("ERR_AWS_CREDENTIALS", format_args!($($arg)*)) }; +} + +fn creds( + access_key_id: Box<[u8]>, + secret_access_key: Box<[u8]>, + session_token: Option>, + expiration: Option, + account_id: Option>, + source: CredentialsSource, +) -> AwsCredentials { + AwsCredentials { + access_key_id, + secret_access_key, + session_token: session_token.unwrap_or_default(), + expiration, + account_id, + region: None, + source, + } +} + +/// `sts`, `portal.sso`, `oidc` hosts by partition. +pub fn dns_suffix(region: &[u8]) -> &'static str { + if region.starts_with(b"cn-") { + "amazonaws.com.cn" + } else if region.starts_with(b"us-iso-") { + "c2s.ic.gov" + } else if region.starts_with(b"us-isob-") { + "sc2s.sgov.gov" + } else if region.starts_with(b"eu-isoe-") { + "cloud.adc-e.uk" + } else if region.starts_with(b"us-isof-") { + "csp.hci.ic.gov" + } else { + "amazonaws.com" + } +} + +fn is_valid_region(region: &[u8]) -> bool { + !region.is_empty() + && region.len() <= 32 + && region + .iter() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'-') +} + +fn snippet(body: &[u8]) -> &BStr { + let body = body.trim_ascii(); + BStr::new(&body[..body.len().min(240)]) +} + +impl Resolver { + fn note(&mut self, args: core::fmt::Arguments<'_>) { + if !self.notes.is_empty() { + self.notes.extend_from_slice(b"; "); + } + let _ = self.notes.write_fmt(args); + } + + /// One round-trip on the HTTP thread, through the configured proxy if any. + async fn http(&self, mut req: HttpRequest, proxied: bool) -> Result { + if proxied { + req.proxy_url = self.cfg.proxy_for(&req.url).map(Box::from); + } + self.io.http(req).await + } + + async fn run(&mut self) -> ChainResult { + if let Some(c) = self.from_env()? { + return Ok(c); + } + let profile = self.cfg.effective_profile().to_vec(); + let mut visited: Vec> = Vec::new(); + let from_profile = self.from_profile(&profile, &mut visited, 0).await; + // A profile that comes up short while a config file could not be read: + // say so, whatever the specific complaint was. + let from_profile = from_profile.map_err(|mut e| { + if !self.unreadable_files.is_empty() { + let mut m = e.message.into_vec(); + m.extend_from_slice(b"; could not read "); + m.extend_from_slice(&self.unreadable_files); + e.message = m.into_boxed_slice(); + } + e + }); + if let Some(mut c) = from_profile? { + if c.region.is_none() { + c.region = self + .profile_region(&profile) + .or_else(|| self.cfg.region.clone()); + } + return Ok(c); + } + if let Some(mut c) = self.from_web_identity_env().await? { + c.region = self + .cfg + .region + .clone() + .or_else(|| self.profile_region(&profile)); + return Ok(c); + } + if let Some(mut c) = self.from_container().await? { + c.region = self + .cfg + .region + .clone() + .or_else(|| self.profile_region(&profile)); + return Ok(c); + } + if let Some(mut c) = self.from_imds().await? { + c.region = self + .cfg + .region + .clone() + .or_else(|| self.profile_region(&profile)); + return Ok(c); + } + Err(err( + "ERR_AWS_MISSING_CREDENTIALS", + format_args!( + "Could not find AWS credentials in any source: {}", + BStr::new(&self.notes) + ), + )) + } + + // ── 1. environment ──────────────────────────────────────────────────── + + fn from_env(&mut self) -> Outcome { + if self.cfg.profile_is_explicit() { + self.note(format_args!( + "environment (skipped because a profile is selected)" + )); + return Ok(None); + } + match self.env_static() { + Some(c) => Ok(Some(c)), + None => { + self.note(format_args!( + "environment (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY not set)" + )); + Ok(None) + } + } + } + + fn env_static(&self) -> Option { + let (Some(akid), Some(secret)) = (&self.cfg.access_key_id, &self.cfg.secret_access_key) + else { + return None; + }; + let mut c = creds( + akid.clone(), + secret.clone(), + self.cfg.session_token.clone(), + None, + self.cfg.account_id.clone(), + CredentialsSource::Env, + ); + c.region.clone_from(&self.cfg.region); + Some(c) + } + + // ── 2. shared config / credentials files ────────────────────────────── + + fn load_files(&mut self) { + if self.config.is_some() { + return; + } + let (config, credentials) = ( + self.cfg.config_file_path(), + self.cfg.credentials_file_path(), + ); + self.config = Some(self.read_ini(config, true)); + self.credentials = Some(self.read_ini(credentials, false)); + } + + fn read_ini(&mut self, path: Option>, is_config: bool) -> IniFile { + let Some(path) = path else { + return IniFile::default(); + }; + match File::read_from(Fd::cwd(), &path) { + Ok(bytes) => IniFile::parse(&bytes, is_config), + Err(e) => { + if !crate::webcore::cloud::not_found(&e) { + if !self.unreadable_files.is_empty() { + self.unreadable_files.extend_from_slice(b", "); + } + let _ = write!( + &mut self.unreadable_files, + "{} ({})", + BStr::new(&path), + BStr::new(e.name()) + ); + self.note(format_args!( + "{} (could not be read: {})", + BStr::new(&path), + BStr::new(e.name()) + )); + } + IniFile::default() + } + } + } + + fn profile(&mut self, name: &[u8]) -> Option> { + self.load_files(); + Profile::lookup( + name, + self.credentials.as_ref().unwrap(), + self.config.as_ref().unwrap(), + ) + } + + fn profile_region(&mut self, name: &[u8]) -> Option> { + self.profile(name) + .and_then(|p| p.get(b"region").map(Box::from)) + } + + /// Boxed because `source_profile` makes it recursive. + fn from_profile<'a>( + &'a mut self, + name: &'a [u8], + visited: &'a mut Vec>, + depth: usize, + ) -> core::pin::Pin + 'a>> { + Box::pin(self.from_profile_inner(name, visited, depth)) + } + + async fn from_profile_inner( + &mut self, + name: &[u8], + visited: &mut Vec>, + depth: usize, + ) -> Outcome { + if depth >= MAX_PROFILE_DEPTH { + return Err(fail!( + "profile \"{}\": source_profile chain is too deep", + BStr::new(name) + )); + } + if visited.iter().any(|v| &**v == name) { + return Err(fail!( + "profile \"{}\": source_profile chain loops back on itself", + BStr::new(name) + )); + } + visited.push(Box::from(name)); + + let explicit = depth > 0 || self.cfg.profile.is_some(); + // Copy out what we need so `self` is free for the network calls below. + struct P { + access_key_id: Option>, + secret_access_key: Option>, + session_token: Option>, + account_id: Option>, + role_arn: Option>, + source_profile: Option>, + credential_source: Option>, + role_session_name: Option>, + external_id: Option>, + duration_seconds: Option>, + mfa_serial: Option>, + web_identity_token_file: Option>, + credential_process: Option>, + sso_session: Option>, + sso_start_url: Option>, + sso_region: Option>, + sso_account_id: Option>, + sso_role_name: Option>, + region: Option>, + } + let config_path = self.cfg.config_file_path(); + let credentials_path = self.cfg.credentials_file_path(); + let p = match self.profile(name) { + Some(p) => { + let g = |k: &[u8]| p.get(k).map(Box::from); + P { + access_key_id: g(b"aws_access_key_id"), + secret_access_key: g(b"aws_secret_access_key"), + session_token: g(b"aws_session_token"), + account_id: g(b"aws_account_id"), + role_arn: g(b"role_arn"), + source_profile: g(b"source_profile"), + credential_source: g(b"credential_source"), + role_session_name: g(b"role_session_name"), + external_id: g(b"external_id"), + duration_seconds: g(b"duration_seconds"), + mfa_serial: g(b"mfa_serial"), + web_identity_token_file: g(b"web_identity_token_file"), + credential_process: g(b"credential_process"), + sso_session: g(b"sso_session"), + sso_start_url: g(b"sso_start_url"), + sso_region: g(b"sso_region"), + sso_account_id: g(b"sso_account_id"), + sso_role_name: g(b"sso_role_name"), + region: g(b"region"), + } + } + None => { + if explicit { + return Err(fail!( + "profile \"{}\" was not found in {} or {}", + BStr::new(name), + BStr::new(credentials_path.as_deref().unwrap_or(b"~/.aws/credentials")), + BStr::new(config_path.as_deref().unwrap_or(b"~/.aws/config")), + )); + } + self.note(format_args!( + "profile \"{}\" (not found in {} or {})", + BStr::new(name), + BStr::new(credentials_path.as_deref().unwrap_or(b"~/.aws/credentials")), + BStr::new(config_path.as_deref().unwrap_or(b"~/.aws/config")), + )); + return Ok(None); + } + }; + + // (a) assume role via source_profile / credential_source + if let Some(role_arn) = &p.role_arn + && (p.source_profile.is_some() || p.credential_source.is_some()) + { + if p.mfa_serial.is_some() { + return Err(fail!( + "profile \"{}\": mfa_serial requires an interactive MFA prompt, which is not supported", + BStr::new(name) + )); + } + let source = if let Some(src) = &p.source_profile { + if &**src == name { + // Self-referencing source_profile means "use my own static keys". + match (&p.access_key_id, &p.secret_access_key) { + (Some(a), Some(s)) => creds( + a.clone(), + s.clone(), + p.session_token.clone(), + None, + None, + CredentialsSource::Profile, + ), + _ => { + return Err(fail!( + "profile \"{}\": source_profile points at itself but has no static credentials", + BStr::new(name) + )); + } + } + } else { + match self.from_profile(src, visited, depth + 1).await? { + Some(c) => c, + None => { + return Err(fail!( + "profile \"{}\": source_profile \"{}\" did not yield credentials", + BStr::new(name), + BStr::new(src) + )); + } + } + } + } else { + let cs = p.credential_source.as_deref().unwrap(); + let got = if cs.eq_ignore_ascii_case(b"Environment") { + self.env_static() + } else if cs.eq_ignore_ascii_case(b"Ec2InstanceMetadata") { + self.from_imds().await? + } else if cs.eq_ignore_ascii_case(b"EcsContainer") { + self.from_container().await? + } else { + return Err(fail!( + "profile \"{}\": unsupported credential_source \"{}\" (expected Environment, Ec2InstanceMetadata or EcsContainer)", + BStr::new(name), + BStr::new(cs) + )); + }; + match got { + Some(c) => c, + None => { + return Err(fail!( + "profile \"{}\": credential_source {} did not yield credentials", + BStr::new(name), + BStr::new(cs) + )); + } + } + }; + let region = p.region.clone().or_else(|| self.cfg.region.clone()); + let mut c = self + .assume_role( + name, + &source, + role_arn, + p.role_session_name.as_deref(), + p.external_id.as_deref(), + p.duration_seconds.as_deref(), + region.as_deref(), + ) + .await?; + c.region.clone_from(&p.region); + return Ok(Some(c)); + } + + // (b) static keys + if let (Some(a), Some(s)) = (&p.access_key_id, &p.secret_access_key) { + let mut c = creds( + a.clone(), + s.clone(), + p.session_token.clone(), + None, + p.account_id.clone(), + CredentialsSource::Profile, + ); + c.region.clone_from(&p.region); + return Ok(Some(c)); + } + + // (c) web identity token file + if let (Some(token_file), Some(role_arn)) = (&p.web_identity_token_file, &p.role_arn) { + let region = p.region.clone().or_else(|| self.cfg.region.clone()); + let token_file = self.cfg.expand_home(token_file); + let mut c = self + .assume_role_with_web_identity( + &token_file, + role_arn, + p.role_session_name.as_deref(), + region.as_deref(), + ) + .await?; + c.region.clone_from(&p.region); + return Ok(Some(c)); + } + + // (d) credential_process + if let Some(cmd) = &p.credential_process { + let mut c = self.from_process(name, cmd).await?; + c.region.clone_from(&p.region); + return Ok(Some(c)); + } + + // (e) SSO + if p.sso_account_id.is_some() + || p.sso_role_name.is_some() + || p.sso_session.is_some() + || p.sso_start_url.is_some() + { + let (Some(account_id), Some(role_name)) = (&p.sso_account_id, &p.sso_role_name) else { + return Err(fail!( + "profile \"{}\": SSO profiles need both sso_account_id and sso_role_name", + BStr::new(name) + )); + }; + let (start_url, sso_region, session_name) = if let Some(session) = &p.sso_session { + self.load_files(); + let cfg = self.config.as_ref().unwrap(); + let Some(sec) = cfg.section(SectionKind::SsoSession, session) else { + return Err(fail!( + "profile \"{}\": sso_session \"{}\" has no [sso-session {}] section in {}", + BStr::new(name), + BStr::new(session), + BStr::new(session), + BStr::new(config_path.as_deref().unwrap_or(b"~/.aws/config")), + )); + }; + let (Some(u), Some(r)) = (sec.get(b"sso_start_url"), sec.get(b"sso_region")) else { + return Err(fail!( + "[sso-session {}] needs sso_start_url and sso_region", + BStr::new(session) + )); + }; + ( + Box::<[u8]>::from(u), + Box::<[u8]>::from(r), + Some(session.clone()), + ) + } else { + let (Some(u), Some(r)) = (&p.sso_start_url, &p.sso_region) else { + return Err(fail!( + "profile \"{}\": legacy SSO profiles need sso_start_url and sso_region", + BStr::new(name) + )); + }; + (u.clone(), r.clone(), None) + }; + let mut c = self + .from_sso( + name, + &start_url, + &sso_region, + session_name.as_deref(), + account_id, + role_name, + ) + .await?; + c.region.clone_from(&p.region); + return Ok(Some(c)); + } + + if p.role_arn.is_some() { + return Err(fail!( + "profile \"{}\" has role_arn but no source_profile, credential_source or web_identity_token_file", + BStr::new(name) + )); + } + if explicit { + return Err(fail!( + "profile \"{}\" does not contain credentials (expected aws_access_key_id/aws_secret_access_key, role_arn, credential_process, web_identity_token_file or sso_*)", + BStr::new(name) + )); + } + self.note(format_args!( + "profile \"{}\" (has no credential settings)", + BStr::new(name) + )); + Ok(None) + } + + // ── STS ─────────────────────────────────────────────────────────────── + + fn sts_endpoint(&self, region: Option<&[u8]>) -> Result<(Vec, Box<[u8]>), ProviderError> { + let region: &[u8] = region.filter(|r| !r.is_empty()).unwrap_or(b"us-east-1"); + if !is_valid_region(region) { + return Err(fail!("invalid AWS region \"{}\"", BStr::new(region))); + } + if let Some(ep) = &self.cfg.endpoint_url_sts { + let mut url = strings::trim_right(ep, b"/").to_vec(); + url.push(b'/'); + return Ok((url, Box::from(region))); + } + let mut url = Vec::with_capacity(48); + if self.cfg.sts_regional_endpoints_legacy && dns_suffix(region) == "amazonaws.com" { + url.extend_from_slice(b"https://sts.amazonaws.com/"); + return Ok((url, Box::from(b"us-east-1".as_slice()))); + } + let _ = write!( + &mut url, + "https://sts.{}.{}/", + BStr::new(region), + dns_suffix(region) + ); + Ok((url, Box::from(region))) + } + + fn default_session_name() -> Vec { + let ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + format!("bun-{ms}").into_bytes() + } + + fn sts_credentials_from_xml( + &self, + what: &str, + status: u32, + body: &[u8], + result_el: &[u8], + source: CredentialsSource, + ) -> Result { + if status != 200 { + let (code, message) = xml_response::parse(body, |root| { + // + let e = root.child(b"Error").unwrap_or(root); + ( + e.child_nonempty_text(b"Code"), + e.child_nonempty_text(b"Message"), + ) + }) + .unwrap_or((None, None)); + return Err(fail!( + "{what} failed with HTTP {status}: {} {}", + BStr::new(code.as_deref().unwrap_or(b"")), + BStr::new( + message + .as_deref() + .unwrap_or_else(|| &body[..body.len().min(240)]) + ), + )); + } + let parsed = xml_response::parse(body, |root| { + let c = root.child(result_el)?.child(b"Credentials")?; + Some(( + c.child_nonempty_text(b"AccessKeyId")?, + c.child_nonempty_text(b"SecretAccessKey")?, + c.child_nonempty_text(b"SessionToken")?, + c.child_nonempty_text(b"Expiration"), + )) + }) + .flatten(); + let Some((akid, secret, token, expiration)) = parsed else { + return Err(fail!( + "{what} returned an unexpected response: {}", + snippet(body) + )); + }; + Ok(creds( + akid, + secret, + Some(token), + expiration.as_deref().and_then(sigv4::parse_iso8601), + None, + source, + )) + } + + #[allow(clippy::too_many_arguments)] + async fn assume_role( + &self, + profile: &[u8], + source: &AwsCredentials, + role_arn: &[u8], + session_name: Option<&[u8]>, + external_id: Option<&[u8]>, + duration_seconds: Option<&[u8]>, + region: Option<&[u8]>, + ) -> Result { + let (url, region) = self.sts_endpoint(region)?; + let default_name = Self::default_session_name(); + let mut body = Vec::with_capacity(256); + let mut pairs: Vec<(&[u8], &[u8])> = vec![ + (b"Action", b"AssumeRole"), + (b"Version", b"2011-06-15"), + (b"RoleArn", role_arn), + (b"RoleSessionName", session_name.unwrap_or(&default_name)), + ]; + if let Some(e) = external_id { + pairs.push((b"ExternalId", e)); + } + if let Some(d) = duration_seconds { + pairs.push((b"DurationSeconds", d)); + } + form_encode(&mut body, &pairs); + + let parsed = bun_url::URL::parse(&url); + let host = parsed.host; + let signed = sigv4::sign( + &source.sigv4(), + &sigv4::Request { + method: b"POST", + host, + path: parsed.raw_pathname(), + query: b"", + headers: &[( + b"content-type", + b"application/x-www-form-urlencoded; charset=utf-8", + )], + payload: sigv4::Payload::Bytes(&body), + scope: sigv4::Scope { + service: b"sts", + region: ®ion, + }, + datetime: None, + s3_path_semantics: Some(false), + }, + ) + .map_err(|e| { + fail!( + "profile \"{}\": could not sign STS AssumeRole request: {e:?}", + BStr::new(profile) + ) + })?; + + let mut req = HttpRequest::post(url.clone(), body) + .header( + b"content-type", + b"application/x-www-form-urlencoded; charset=utf-8", + ) + .header(b"authorization", &signed.authorization) + .header(b"x-amz-date", &signed.amz_date) + .header(b"accept", b"application/xml") + .timeout(STS_TIMEOUT_MS); + if let Some(t) = source.session_token() { + req = req.header(b"x-amz-security-token", t); + } + let res = self.http(req, true).await.map_err(|e| { + fail!( + "profile \"{}\": STS AssumeRole request to {} failed: {e}", + BStr::new(profile), + BStr::new(&url) + ) + })?; + self.sts_credentials_from_xml( + "STS AssumeRole", + res.status, + &res.body, + b"AssumeRoleResult", + CredentialsSource::AssumeRole, + ) + } + + async fn assume_role_with_web_identity( + &self, + token_file: &[u8], + role_arn: &[u8], + session_name: Option<&[u8]>, + region: Option<&[u8]>, + ) -> Result { + let token = File::read_from(Fd::cwd(), token_file).map_err(|e| { + fail!( + "could not read web identity token file {}: {}", + BStr::new(token_file), + BStr::new(e.name()) + ) + })?; + let token = token.trim_ascii(); + if token.is_empty() { + return Err(fail!( + "web identity token file {} is empty", + BStr::new(token_file) + )); + } + let (url, _region) = self.sts_endpoint(region)?; + let default_name = self + .cfg + .role_session_name + .as_deref() + .map(<[u8]>::to_vec) + .unwrap_or_else(Self::default_session_name); + let mut body = Vec::with_capacity(512 + token.len()); + form_encode( + &mut body, + &[ + (b"Action", b"AssumeRoleWithWebIdentity"), + (b"Version", b"2011-06-15"), + (b"RoleArn", role_arn), + (b"RoleSessionName", session_name.unwrap_or(&default_name)), + (b"WebIdentityToken", token), + ], + ); + let req = HttpRequest::post(url.clone(), body) + .header( + b"content-type", + b"application/x-www-form-urlencoded; charset=utf-8", + ) + .header(b"accept", b"application/xml") + .timeout(STS_TIMEOUT_MS); + let res = self.http(req, true).await.map_err(|e| { + fail!( + "STS AssumeRoleWithWebIdentity request to {} failed: {e}", + BStr::new(&url) + ) + })?; + self.sts_credentials_from_xml( + "STS AssumeRoleWithWebIdentity", + res.status, + &res.body, + b"AssumeRoleWithWebIdentityResult", + CredentialsSource::WebIdentity, + ) + } + + // ── 3. web identity from env (IRSA) ─────────────────────────────────── + + async fn from_web_identity_env(&mut self) -> Outcome { + let (Some(file), Some(role)) = ( + self.cfg.web_identity_token_file.clone(), + self.cfg.role_arn.clone(), + ) else { + self.note(format_args!( + "web identity (AWS_WEB_IDENTITY_TOKEN_FILE / AWS_ROLE_ARN not set)" + )); + return Ok(None); + }; + let region = self.cfg.region.clone(); + self.assume_role_with_web_identity(&file, &role, None, region.as_deref()) + .await + .map(Some) + } + + // ── credential_process ──────────────────────────────────────────────── + + async fn from_process( + &self, + profile: &[u8], + command: &[u8], + ) -> Result { + #[cfg(windows)] + let argv: [&[u8]; 3] = [b"cmd.exe", b"/C", command]; + #[cfg(not(windows))] + let argv: [&[u8]; 3] = [b"/bin/sh", b"-c", command]; + let result = self + .io + .spawn(SpawnRequest { + argv: argv.iter().map(|a| Box::from(*a)).collect(), + windows_verbatim_arguments: true, + }) + .await + .map_err(|e| { + fail!( + "profile \"{}\": could not run credential_process: {e}", + BStr::new(profile) + ) + })?; + match result.term { + bun_spawn::Term::Exited(0) => {} + term => { + let stderr = result.stderr.trim_ascii(); + return Err(fail!( + "profile \"{}\": credential_process exited with {term:?}{}{}", + BStr::new(profile), + if stderr.is_empty() { "" } else { ": " }, + BStr::new(&stderr[..stderr.len().min(500)]), + )); + } + } + let parsed = json::parse(&result.stdout, |o| { + ( + o.number(b"Version"), + o.str(b"AccessKeyId"), + o.str(b"SecretAccessKey"), + o.str(b"SessionToken"), + o.str(b"Expiration"), + o.str(b"AccountId"), + ) + }); + let Some((version, Some(akid), Some(secret), token, expiration, account_id)) = parsed + else { + return Err(fail!( + "profile \"{}\": credential_process did not print a JSON object with AccessKeyId and SecretAccessKey", + BStr::new(profile) + )); + }; + if version != Some(1.0) { + return Err(fail!( + "profile \"{}\": credential_process output must have \"Version\": 1", + BStr::new(profile) + )); + } + let expiration = match expiration { + Some(e) => match sigv4::parse_iso8601(&e) { + Some(t) => Some(t), + None => { + return Err(fail!( + "profile \"{}\": credential_process printed an invalid Expiration \"{}\"", + BStr::new(profile), + BStr::new(&e) + )); + } + }, + None => None, + }; + Ok(creds( + akid, + secret, + token, + expiration, + account_id, + CredentialsSource::Process, + )) + } + + // ── SSO ─────────────────────────────────────────────────────────────── + + async fn from_sso( + &self, + profile: &[u8], + start_url: &[u8], + sso_region: &[u8], + session_name: Option<&[u8]>, + account_id: &[u8], + role_name: &[u8], + ) -> Result { + if !is_valid_region(sso_region) { + return Err(fail!( + "profile \"{}\": invalid sso_region \"{}\"", + BStr::new(profile), + BStr::new(sso_region) + )); + } + // Token cache: ~/.aws/sso/cache/.json + let key_input = session_name.unwrap_or(start_url); + let mut digest = [0u8; bun_sha_hmac::sha::hashers::SHA1::DIGEST]; + bun_sha_hmac::sha::hashers::SHA1::hash(key_input, &mut digest); + let Some(dir) = self.cfg.sso_cache_dir() else { + return Err(fail!( + "profile \"{}\": cannot locate the SSO token cache (HOME is not set)", + BStr::new(profile) + )); + }; + let mut path = dir; + let _ = write!( + &mut path, + "{}{}.json", + bun_paths::SEP_STR, + bun_core::fmt::hex_lower(&digest) + ); + let login_hint = || -> String { + match session_name { + Some(s) => format!("run `aws sso login --sso-session {}`", BStr::new(s)), + None => format!("run `aws sso login --profile {}`", BStr::new(profile)), + } + }; + let cache = File::read_from(Fd::cwd(), &path).map_err(|e| { + let why = if crate::webcore::cloud::not_found(&e) { + format!( + "no cached SSO token at {}; {}", + BStr::new(&path), + login_hint() + ) + } else { + format!( + "could not read the cached SSO token at {} ({})", + BStr::new(&path), + BStr::new(e.name()) + ) + }; + fail!("profile \"{}\": {why}", BStr::new(profile)) + })?; + struct Token { + access_token: Option>, + expires_at: Option>, + refresh_token: Option>, + client_id: Option>, + client_secret: Option>, + registration_expires_at: Option>, + } + let Some(mut tok) = json::parse(&cache, |o| Token { + access_token: o.str(b"accessToken"), + expires_at: o.str(b"expiresAt"), + refresh_token: o.str(b"refreshToken"), + client_id: o.str(b"clientId"), + client_secret: o.str(b"clientSecret"), + registration_expires_at: o.str(b"registrationExpiresAt"), + }) else { + return Err(fail!( + "profile \"{}\": SSO token cache {} is not valid JSON; {}", + BStr::new(profile), + BStr::new(&path), + login_hint() + )); + }; + let now = now_secs(); + let expires_at = tok + .expires_at + .as_deref() + .and_then(sigv4::parse_iso8601) + .unwrap_or(0); + let mut access_token = tok.access_token.take(); + if access_token.is_none() || expires_at <= now + 60 { + // Try a refresh if the cache carries a registered client. + let registration_ok = tok + .registration_expires_at + .as_deref() + .and_then(sigv4::parse_iso8601) + .is_none_or(|t| t > now); + if let (Some(rt), Some(cid), Some(cs), true) = ( + &tok.refresh_token, + &tok.client_id, + &tok.client_secret, + registration_ok, + ) { + access_token = self + .sso_refresh(profile, sso_region, &path, &cache, rt, cid, cs) + .await?; + } else { + return Err(fail!( + "profile \"{}\": the cached SSO token has expired; {}", + BStr::new(profile), + login_hint() + )); + } + } + let Some(access_token) = access_token else { + return Err(fail!( + "profile \"{}\": SSO token cache has no accessToken; {}", + BStr::new(profile), + login_hint() + )); + }; + + let mut url = Vec::with_capacity(128); + let _ = write!( + &mut url, + "https://portal.sso.{}.{}/federation/credentials?", + BStr::new(sso_region), + dns_suffix(sso_region) + ); + form_encode( + &mut url, + &[(b"account_id", account_id), (b"role_name", role_name)], + ); + let req = HttpRequest::get(url) + .header(b"x-amz-sso_bearer_token", &access_token) + .header(b"accept", b"application/json") + .timeout(STS_TIMEOUT_MS); + let res = self.http(req, true).await.map_err(|e| { + fail!( + "profile \"{}\": SSO GetRoleCredentials request failed: {e}", + BStr::new(profile) + ) + })?; + if res.status == 401 || res.status == 403 { + return Err(fail!( + "profile \"{}\": SSO GetRoleCredentials was rejected (HTTP {}): {}; {}", + BStr::new(profile), + res.status, + snippet(&res.body), + login_hint() + )); + } + if res.status != 200 { + return Err(fail!( + "profile \"{}\": SSO GetRoleCredentials failed with HTTP {}: {}", + BStr::new(profile), + res.status, + snippet(&res.body) + )); + } + let parsed = json::parse(&res.body, |o| { + let rc = o.object(b"roleCredentials")?; + Some(( + rc.str(b"accessKeyId")?, + rc.str(b"secretAccessKey")?, + rc.str(b"sessionToken"), + rc.number(b"expiration"), + )) + }) + .flatten(); + let Some((akid, secret, token, expiration_ms)) = parsed else { + return Err(fail!( + "profile \"{}\": SSO GetRoleCredentials returned an unexpected response: {}", + BStr::new(profile), + snippet(&res.body) + )); + }; + Ok(creds( + akid, + secret, + token, + expiration_ms + .filter(|m| m.is_finite() && *m > 0.0) + .map(|m| (m / 1000.0) as u64), + Some(Box::from(account_id)), + CredentialsSource::Sso, + )) + } + + #[allow(clippy::too_many_arguments)] + async fn sso_refresh( + &self, + profile: &[u8], + sso_region: &[u8], + cache_path: &[u8], + cache_body: &[u8], + refresh_token: &[u8], + client_id: &[u8], + client_secret: &[u8], + ) -> Result>, ProviderError> { + let mut url = Vec::with_capacity(64); + let _ = write!( + &mut url, + "https://oidc.{}.{}/token", + BStr::new(sso_region), + dns_suffix(sso_region) + ); + let mut body = Vec::with_capacity(256 + refresh_token.len()); + body.extend_from_slice(b"{\"clientId\":"); + json::push_string(&mut body, client_id); + body.extend_from_slice(b",\"clientSecret\":"); + json::push_string(&mut body, client_secret); + body.extend_from_slice(b",\"grantType\":\"refresh_token\",\"refreshToken\":"); + json::push_string(&mut body, refresh_token); + body.push(b'}'); + let req = HttpRequest::post(url, body) + .header(b"content-type", b"application/json") + .timeout(STS_TIMEOUT_MS); + let res = self.http(req, true).await.map_err(|e| { + fail!( + "profile \"{}\": refreshing the SSO token failed: {e}", + BStr::new(profile) + ) + })?; + if res.status != 200 { + return Err(fail!( + "profile \"{}\": the cached SSO token has expired and refreshing it failed (HTTP {}); run `aws sso login`", + BStr::new(profile), + res.status + )); + } + let Some((Some(access_token), expires_in, new_refresh)) = json::parse(&res.body, |o| { + ( + o.str(b"accessToken"), + o.number(b"expiresIn"), + o.str(b"refreshToken"), + ) + }) else { + return Err(fail!( + "profile \"{}\": SSO token refresh returned an unexpected response", + BStr::new(profile) + )); + }; + // Best-effort write-back so other tools see the refreshed token. + let Some(expires_in) = expires_in.filter(|s| s.is_finite() && *s > 0.0) else { + return Ok(Some(access_token)); + }; + let expires_at = sigv4::amz_datetime(now_secs() + expires_in as u64); + let iso = format!( + "{}-{}-{}T{}:{}:{}Z", + BStr::new(&expires_at[0..4]), + BStr::new(&expires_at[4..6]), + BStr::new(&expires_at[6..8]), + BStr::new(&expires_at[9..11]), + BStr::new(&expires_at[11..13]), + BStr::new(&expires_at[13..15]), + ); + if let Some(updated) = rewrite_sso_cache( + cache_body, + &access_token, + iso.as_bytes(), + new_refresh.as_deref(), + ) { + write_sso_cache(cache_path, &updated); + } + Ok(Some(access_token)) + } + + // ── 4. container credentials ────────────────────────────────────────── + + async fn from_container(&mut self) -> Outcome { + let url: Vec = if let Some(rel) = &self.cfg.container_relative_uri { + let mut u = b"http://169.254.170.2".to_vec(); + if !rel.starts_with(b"/") { + u.push(b'/'); + } + u.extend_from_slice(rel); + u + } else if let Some(full) = &self.cfg.container_full_uri { + let parsed = bun_url::URL::parse(full); + let host = parsed.hostname; + let allowed = parsed.is_https() + || (parsed.is_http() + && (host == b"localhost" + || host == b"[::1]" + || host == b"169.254.170.2" + || host == b"169.254.170.23" + || host.eq_ignore_ascii_case(b"[fd00:ec2::23]") + || is_ipv4_loopback(host))); + if !allowed { + return Err(fail!( + "AWS_CONTAINER_CREDENTIALS_FULL_URI \"{}\" must be https://, or http:// to a loopback / ECS / EKS link-local address", + BStr::new(full) + )); + } + full.to_vec() + } else { + self.note(format_args!( + "container (AWS_CONTAINER_CREDENTIALS_RELATIVE_URI / _FULL_URI not set)" + )); + return Ok(None); + }; + + let token: Option> = if let Some(file) = &self.cfg.container_auth_token_file { + match File::read_from(Fd::cwd(), file) { + Ok(t) => Some(strings::trim(&t, b" \t\r\n").to_vec()), + Err(e) => { + return Err(fail!( + "could not read AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE {}: {}", + BStr::new(file), + BStr::new(e.name()) + )); + } + } + } else { + self.cfg.container_auth_token.as_deref().map(<[u8]>::to_vec) + }; + if let Some(t) = &token { + if strings::index_of_any(t, b"\r\n").is_some() { + return Err(fail!( + "AWS_CONTAINER_AUTHORIZATION_TOKEN contains a newline" + )); + } + } + let mut last_err = None; + for _ in 0..self.cfg.imds_attempts.max(1) { + let mut req = HttpRequest::get(url.clone()) + .header(b"accept", b"application/json") + .timeout(self.cfg.imds_timeout_ms.max(1000)); + if let Some(t) = &token { + req = req.header(b"authorization", t); + } + match self.http(req, false).await { + Ok(res) if res.status == 200 => { + return parse_json_credentials( + "container credentials endpoint", + &res.body, + CredentialsSource::Container, + ) + .map(Some); + } + Ok(res) if res.status >= 500 => { + last_err = Some(fail!( + "container credentials endpoint {} answered HTTP {}: {}", + BStr::new(&url), + res.status, + snippet(&res.body) + )); + } + Ok(res) => { + return Err(fail!( + "container credentials endpoint {} answered HTTP {}: {}", + BStr::new(&url), + res.status, + snippet(&res.body) + )); + } + Err(e) => { + let interrupted = e.is_interruption(); + last_err = Some(fail!( + "container credentials endpoint {} is unreachable: {e}", + BStr::new(&url) + )); + if interrupted { + break; + } + } + } + } + Err(last_err.expect("attempts >= 1")) + } + + // ── 5. EC2 instance metadata ────────────────────────────────────────── + + async fn from_imds(&mut self) -> Outcome { + if self.cfg.imds_disabled { + self.note(format_args!( + "EC2 instance metadata (AWS_EC2_METADATA_DISABLED is set)" + )); + return Ok(None); + } + let base: Vec = match &self.cfg.imds_endpoint { + Some(ep) => { + let parsed = bun_url::URL::parse(ep); + if !(parsed.is_http() || parsed.is_https()) || parsed.hostname.is_empty() { + return Err(fail!( + "AWS_EC2_METADATA_SERVICE_ENDPOINT \"{}\" is not an http(s) URL", + BStr::new(ep) + )); + } + strings::trim_right(ep, b"/").to_vec() + } + None if self.cfg.imds_ipv6 => b"http://[fd00:ec2::254]".to_vec(), + None => b"http://169.254.169.254".to_vec(), + }; + let timeout = self.cfg.imds_timeout_ms; + let attempts = self.cfg.imds_attempts.max(1); + let join = |path: &str| -> Vec { + let mut u = base.clone(); + u.extend_from_slice(path.as_bytes()); + u + }; + + // IMDSv2 session token. + let mut token: Option> = None; + let mut token_put_error: Option = None; + let put = HttpRequest::new(Method::PUT, join("/latest/api/token")) + .header(b"x-aws-ec2-metadata-token-ttl-seconds", b"21600") + .timeout(timeout); + match self.http(put, false).await { + Ok(res) if res.status == 200 => { + let t = strings::trim(&res.body, b" \t\r\n"); + if t.is_empty() || strings::index_of_any(t, b"\r\n").is_some() { + return Err(fail!( + "EC2 instance metadata returned an invalid session token" + )); + } + token = Some(t.to_vec()); + } + Ok(res) if matches!(res.status, 401 | 403 | 404 | 405) => { + if self.cfg.imds_v1_disabled { + return Err(fail!( + "EC2 instance metadata token request answered HTTP {} and IMDSv1 fallback is disabled (AWS_EC2_METADATA_V1_DISABLED)", + res.status + )); + } + // fall through to IMDSv1 + } + Ok(res) => { + return Err(fail!( + "EC2 instance metadata token request answered HTTP {}: {}", + res.status, + snippet(&res.body) + )); + } + Err(e) => { + // No answer to the token PUT: either not on EC2, or IMDSv2's + // response cannot reach us (container with hop limit 1). Try + // one IMDSv1 GET before giving up, like the SDKs do. + if self.cfg.imds_v1_disabled { + self.note(format_args!( + "EC2 instance metadata ({} is unreachable: {e})", + BStr::new(&base) + )); + return Ok(None); + } + token_put_error = Some(e); + } + } + + let attempts = if token_put_error.is_some() { + 1 + } else { + attempts + }; + + let role_url = join("/latest/meta-data/iam/security-credentials/"); + let res = match ( + self.imds_get(&role_url, token.as_deref(), attempts).await, + &token_put_error, + ) { + (Ok(res), _) => res, + (Err(_), Some(e)) => { + self.note(format_args!( + "EC2 instance metadata ({} is unreachable: {e})", + BStr::new(&base) + )); + return Ok(None); + } + (Err(e), None) => return Err(e), + }; + if res.status == 401 + && let Some(e) = &token_put_error + { + return Err(fail!( + "EC2 instance metadata requires IMDSv2 but the session token request got no response ({e}); if this is a container, raise the instance's metadata hop limit to 2" + )); + } + if res.status == 404 { + self.note(format_args!( + "EC2 instance metadata (no IAM role is attached to this instance)" + )); + return Ok(None); + } + if res.status != 200 { + return Err(fail!( + "EC2 instance metadata {} answered HTTP {}: {}", + BStr::new(&role_url), + res.status, + snippet(&res.body) + )); + } + let role = strings::split(&res.body, b"\n") + .map(|l| strings::trim(l, b" \t\r")) + .find(|l| !l.is_empty()) + .map(<[u8]>::to_vec); + let Some(role) = role else { + self.note(format_args!( + "EC2 instance metadata (no IAM role is attached to this instance)" + )); + return Ok(None); + }; + if !role.iter().all(|c| { + c.is_ascii_alphanumeric() || matches!(c, b'+' | b'=' | b',' | b'.' | b'@' | b'_' | b'-') + }) { + return Err(fail!("EC2 instance metadata returned an invalid role name")); + } + let mut creds_url = role_url; + creds_url.extend_from_slice(&role); + let res = self + .imds_get(&creds_url, token.as_deref(), attempts) + .await?; + if res.status != 200 { + return Err(fail!( + "EC2 instance metadata {} answered HTTP {}: {}", + BStr::new(&creds_url), + res.status, + snippet(&res.body) + )); + } + parse_json_credentials("EC2 instance metadata", &res.body, CredentialsSource::Imds) + .map(Some) + } + + async fn imds_get( + &self, + url: &[u8], + token: Option<&[u8]>, + attempts: u32, + ) -> Result { + let mut last = None; + for _ in 0..attempts.max(1) { + let mut req = HttpRequest::get(url.to_vec()).timeout(self.cfg.imds_timeout_ms); + if let Some(t) = token { + req = req.header(b"x-aws-ec2-metadata-token", t); + } + match self.http(req, false).await { + Ok(res) if res.status >= 500 => { + last = Some(fail!( + "EC2 instance metadata {} answered HTTP {}", + BStr::new(url), + res.status + )); + } + Ok(res) => return Ok(res), + Err(e) => { + let interrupted = e.is_interruption(); + last = Some(fail!( + "EC2 instance metadata {} is unreachable: {e}", + BStr::new(url) + )); + if interrupted { + break; + } + } + } + } + Err(last.expect("attempts >= 1")) + } +} + +fn is_ipv4_loopback(host: &[u8]) -> bool { + // 127.0.0.0/8 + let parts: Vec<&[u8]> = strings::split(host, b".").collect(); + parts.len() == 4 + && parts[0] == b"127" + && parts[1..] + .iter() + .all(|p| !p.is_empty() && p.len() <= 3 && p.iter().all(u8::is_ascii_digit)) +} + +/// `{AccessKeyId, SecretAccessKey, Token, Expiration, AccountId, Code?}` +fn parse_json_credentials( + what: &str, + body: &[u8], + source: CredentialsSource, +) -> Result { + let parsed = json::parse(body, |o| { + ( + o.str(b"Code"), + o.str(b"AccessKeyId"), + o.str(b"SecretAccessKey"), + o.str(b"Token"), + o.str(b"Expiration"), + o.str(b"AccountId"), + o.str(b"Message"), + ) + }); + let Some((code, akid, secret, token, expiration, account_id, message)) = parsed else { + return Err(fail!( + "{what} returned a response that is not JSON: {}", + snippet(body) + )); + }; + if let Some(code) = &code { + if !code.eq_ignore_ascii_case(b"Success") { + return Err(fail!( + "{what} returned Code \"{}\": {}", + BStr::new(code), + BStr::new(message.as_deref().unwrap_or(b"")) + )); + } + } + let (Some(akid), Some(secret)) = (akid, secret) else { + return Err(fail!( + "{what} response is missing AccessKeyId/SecretAccessKey" + )); + }; + let expiration = match expiration { + Some(e) => Some(sigv4::parse_iso8601(&e).ok_or_else(|| { + fail!( + "{what} returned an invalid Expiration \"{}\"", + BStr::new(&e) + ) + })?), + None => None, + }; + Ok(creds(akid, secret, token, expiration, account_id, source)) +} + +/// Best-effort: replace the cache file whole or not at all (other tools read +/// it), keeping its owner so `sudo bun …` does not lock the user out of it. +fn write_sso_cache(cache_path: &[u8], contents: &[u8]) { + let mut tmp = cache_path.to_vec(); + let _ = write!( + &mut tmp, + ".bun-{}-{:x}", + std::process::id(), + bun_core::time::nano_timestamp() + ); + let tmp = bun_core::ZBox::from_vec(tmp); + let dest = bun_core::ZBox::from_bytes(cache_path); + let replaced = File::openat( + Fd::cwd(), + tmp.as_bytes(), + bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::EXCL | bun_sys::O::CLOEXEC, + 0o600, + ) + .and_then(|f| { + #[cfg(unix)] + if let Ok(st) = bun_sys::fstatat(Fd::cwd(), &dest) { + let _ = bun_sys::fchown(f.handle(), st.st_uid as u32, st.st_gid as u32); + } + f.write_all(contents) + }) + .and_then(|()| bun_sys::renameat(Fd::cwd(), &tmp, Fd::cwd(), &dest)); + if replaced.is_err() { + let _ = bun_sys::unlinkat(Fd::cwd(), &tmp); + } +} + +/// Replace `accessToken` / `expiresAt` (/ `refreshToken`) in the cached SSO +/// token JSON, keeping every other key. Returns `None` if the document is not +/// a flat JSON object we can round-trip. +fn rewrite_sso_cache( + original: &[u8], + access_token: &[u8], + expires_at_iso: &[u8], + refresh_token: Option<&[u8]>, +) -> Option> { + json::parse(original, |o| { + let mut out = Vec::with_capacity(original.len() + 64); + out.push(b'{'); + let mut first = true; + let mut push = |k: &[u8], v: &[u8], out: &mut Vec| { + if !first { + out.push(b','); + } + first = false; + json::push_string(out, k); + out.push(b':'); + json::push_string(out, v); + }; + for prop in o.0.properties() { + let key = prop.key.slice(); + let value: Option> = match key { + b"accessToken" => Some(Box::from(access_token)), + b"expiresAt" => Some(Box::from(expires_at_iso)), + b"refreshToken" => Some(Box::from( + refresh_token.unwrap_or_else(|| prop.value.as_str().unwrap_or(b"")), + )), + _ => prop.value.as_str().map(Box::from), + }; + // Non-string values (there are none in practice) are dropped + // rather than mis-serialised. + if let Some(v) = value { + push(key, &v[..], &mut out); + } + } + out.push(b'}'); + out + }) +} diff --git a/src/runtime/webcore/cloud/aws/config.rs b/src/runtime/webcore/cloud/aws/config.rs new file mode 100644 index 000000000000..0542dc0c96ca --- /dev/null +++ b/src/runtime/webcore/cloud/aws/config.rs @@ -0,0 +1,210 @@ +//! Everything the credential chain reads from the environment, captured +//! from `process.env` up front so one resolution sees one consistent view. + +use bun_core::strings; +use bun_jsc::JSGlobalObject; + +use crate::webcore::cloud::env::Env; + +fn owned(v: Option>) -> Option> { + v.filter(|s| !s.is_empty()).map(Vec::into_boxed_slice) +} + +fn truthy(v: Option<&[u8]>) -> bool { + matches!(v, Some(s) if s.eq_ignore_ascii_case(b"true") || s == b"1") +} + +pub struct ChainConfig { + /// Explicit profile from options; wins over `AWS_PROFILE`. + pub profile: Option>, + + pub aws_profile: Option>, + pub access_key_id: Option>, + pub secret_access_key: Option>, + pub session_token: Option>, + pub account_id: Option>, + pub region: Option>, + pub config_file: Option>, + pub credentials_file: Option>, + pub home: Option>, + + pub web_identity_token_file: Option>, + pub role_arn: Option>, + pub role_session_name: Option>, + + pub container_relative_uri: Option>, + pub container_full_uri: Option>, + pub container_auth_token: Option>, + pub container_auth_token_file: Option>, + + pub imds_disabled: bool, + pub imds_endpoint: Option>, + pub imds_ipv6: bool, + pub imds_v1_disabled: bool, + pub imds_timeout_ms: u32, + pub imds_attempts: u32, + + pub endpoint_url_sts: Option>, + pub sts_regional_endpoints_legacy: bool, + + pub https_proxy: Option>, + pub http_proxy: Option>, + pub no_proxy: Option>, +} + +impl ChainConfig { + pub fn capture(global: &JSGlobalObject, profile: Option<&[u8]>) -> ChainConfig { + let env = Env::new(global); + let timeout_secs: f64 = env + .get(b"AWS_METADATA_SERVICE_TIMEOUT") + .and_then(|s| { + core::str::from_utf8(&s) + .ok() + .and_then(|s| s.trim().parse().ok()) + }) + .filter(|v: &f64| v.is_finite() && *v > 0.0) + .unwrap_or(1.0); + let attempts: u32 = env + .get(b"AWS_METADATA_SERVICE_NUM_ATTEMPTS") + .and_then(|s| { + core::str::from_utf8(&s) + .ok() + .and_then(|s| s.trim().parse().ok()) + }) + .filter(|v: &u32| *v > 0) + .unwrap_or(3); + ChainConfig { + profile: profile.filter(|s| !s.is_empty()).map(Box::from), + aws_profile: owned(env.get(b"AWS_PROFILE")), + access_key_id: owned(env.get(b"AWS_ACCESS_KEY_ID")), + secret_access_key: owned(env.get(b"AWS_SECRET_ACCESS_KEY")), + session_token: owned(env.get(b"AWS_SESSION_TOKEN")), + account_id: owned(env.get(b"AWS_ACCOUNT_ID")), + region: owned( + env.get(b"AWS_REGION") + .or_else(|| env.get(b"AWS_DEFAULT_REGION")), + ), + config_file: owned(env.get(b"AWS_CONFIG_FILE")), + credentials_file: owned(env.get(b"AWS_SHARED_CREDENTIALS_FILE")), + home: owned( + env.get(b"HOME") + .or_else(|| env.get(b"USERPROFILE")) + .or_else(|| bun_core::env_var::HOME.get().map(<[u8]>::to_vec)), + ), + web_identity_token_file: owned(env.get(b"AWS_WEB_IDENTITY_TOKEN_FILE")), + role_arn: owned(env.get(b"AWS_ROLE_ARN")), + role_session_name: owned(env.get(b"AWS_ROLE_SESSION_NAME")), + container_relative_uri: owned(env.get(b"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")), + container_full_uri: owned(env.get(b"AWS_CONTAINER_CREDENTIALS_FULL_URI")), + container_auth_token: owned(env.get(b"AWS_CONTAINER_AUTHORIZATION_TOKEN")), + container_auth_token_file: owned(env.get(b"AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE")), + imds_disabled: truthy(env.get(b"AWS_EC2_METADATA_DISABLED").as_deref()), + imds_endpoint: owned(env.get(b"AWS_EC2_METADATA_SERVICE_ENDPOINT")), + imds_ipv6: env + .get(b"AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE") + .is_some_and(|m| m.eq_ignore_ascii_case(b"ipv6")), + imds_v1_disabled: truthy(env.get(b"AWS_EC2_METADATA_V1_DISABLED").as_deref()), + imds_timeout_ms: (timeout_secs * 1000.0).clamp(50.0, 120_000.0) as u32, + imds_attempts: attempts.min(10), + endpoint_url_sts: owned( + env.get(b"AWS_ENDPOINT_URL_STS") + .or_else(|| env.get(b"AWS_ENDPOINT_URL")), + ), + sts_regional_endpoints_legacy: env + .get(b"AWS_STS_REGIONAL_ENDPOINTS") + .is_some_and(|m| m.eq_ignore_ascii_case(b"legacy")), + https_proxy: owned(env.get_proxy_var(b"https_proxy", b"HTTPS_PROXY")), + http_proxy: owned(env.get_proxy_var(b"http_proxy", b"HTTP_PROXY")), + no_proxy: owned(env.get_proxy_var(b"no_proxy", b"NO_PROXY")), + } + } + + pub fn effective_profile(&self) -> &[u8] { + self.profile + .as_deref() + .or(self.aws_profile.as_deref()) + .unwrap_or(b"default") + } + + pub fn profile_is_explicit(&self) -> bool { + self.profile.is_some() || self.aws_profile.is_some() + } + + fn home_join(&self, rel: &[u8]) -> Option> { + let home = self.home.as_deref()?; + let mut p = Vec::with_capacity(home.len() + 1 + rel.len()); + p.extend_from_slice(strings::trim_right(home, b"/\\")); + p.push(bun_paths::SEP); + let start = p.len(); + p.extend_from_slice(rel); + if cfg!(windows) { + for b in &mut p[start..] { + if *b == b'/' { + *b = bun_paths::SEP; + } + } + } + Some(p) + } + + pub fn config_file_path(&self) -> Option> { + match &self.config_file { + Some(p) => Some(self.expand_home(p)), + None => self.home_join(b".aws/config"), + } + } + + pub fn credentials_file_path(&self) -> Option> { + match &self.credentials_file { + Some(p) => Some(self.expand_home(p)), + None => self.home_join(b".aws/credentials"), + } + } + + pub fn sso_cache_dir(&self) -> Option> { + self.home_join(b".aws/sso/cache") + } + + /// `~/x` → `$HOME/x`; anything else unchanged. + pub fn expand_home(&self, path: &[u8]) -> Vec { + if let Some(rest) = path + .strip_prefix(b"~/".as_slice()) + .or_else(|| path.strip_prefix(b"~\\".as_slice())) + { + if let Some(joined) = self.home_join(rest) { + return joined; + } + } + path.to_vec() + } + + /// The proxy to use for `url`, honouring `NO_PROXY` (`*`, exact host, or + /// domain-suffix entries). Link-local metadata endpoints are never proxied. + pub fn proxy_for(&self, url: &[u8]) -> Option<&[u8]> { + let parsed = bun_url::URL::parse(url); + let host = parsed.hostname; + if host.is_empty() + || host == b"169.254.169.254" + || host == b"169.254.170.2" + || host == b"169.254.170.23" + || host == b"[fd00:ec2::254]" + || host == b"[fd00:ec2::23]" + || host == b"localhost" + || host == b"127.0.0.1" + || host == b"[::1]" + { + return None; + } + let proxy = if parsed.is_https() { + self.https_proxy.as_deref() + } else { + self.http_proxy.as_deref() + }?; + if let Some(no_proxy) = self.no_proxy.as_deref() { + if bun_http::no_proxy_matches(no_proxy, parsed.hostname, parsed.host) { + return None; + } + } + Some(proxy) + } +} diff --git a/src/runtime/webcore/cloud/aws/fetch_signing.rs b/src/runtime/webcore/cloud/aws/fetch_signing.rs new file mode 100644 index 000000000000..9fc679f2ee8c --- /dev/null +++ b/src/runtime/webcore/cloud/aws/fetch_signing.rs @@ -0,0 +1,156 @@ +//! Applies `AwsSignOptions` to an outgoing `fetch()` request. + +use bun_http::Headers; +use bun_http::Method; +use bun_http_types::ETag::HeaderEntryColumns as _; +use bun_jsc::{JSGlobalObject, JSValue, StringJsc as _}; +use bun_s3_signing::ProviderError; +use bun_s3_signing::sigv4; +use bun_url::URL; + +use super::sign_options::{AwsSignOptions, ScopeError}; + +#[derive(Clone, Copy)] +pub enum Body<'a> { + Bytes(&'a [u8]), + /// ReadableStream / sendfile: cannot be hashed up front. + Streaming, +} + +pub enum Signed { + /// `Authorization` etc. were appended to the headers. + Headers, + /// `signQuery`: send to this URL instead. + Url(Box<[u8]>), +} + +#[derive(Debug, thiserror::Error)] +pub enum SignFetchError { + /// The provider's explanation of why nothing is available synchronously. + #[error("{}", bstr::BStr::new(.0))] + NoCredentials(Vec), + #[error(transparent)] + Scope(#[from] ScopeError), + #[error( + "a streaming request body cannot be SigV4-signed for this service because its SHA-256 is not known up front (only S3-style services accept UNSIGNED-PAYLOAD); buffer the body first" + )] + StreamingBody, + #[error("the \"{}\" header is generated by request signing; remove it from headers", bstr::BStr::new(.0))] + ReservedHeader(Box<[u8]>), + #[error("{0}")] + Sign(bun_s3_signing::credentials::SignError), +} + +/// Header names the caller must not set alongside `aws` (we generate them). +const RESERVED: &[&[u8]] = &[ + b"authorization", + b"x-amz-date", + b"x-amz-content-sha256", + b"x-amz-security-token", +]; + +pub fn sign_fetch_request( + global: &bun_jsc::JSGlobalObject, + opts: &AwsSignOptions, + method: Method, + url: &URL<'_>, + headers: &mut Option, + body: Body<'_>, +) -> Result { + let creds = opts.available_credentials().ok_or_else(|| { + SignFetchError::NoCredentials( + opts.provider() + .map(|p| p.pending_message()) + .unwrap_or_default(), + ) + })?; + + // Sign the Host the HTTP client will send: the user's override, else the URL's. + let user_host = headers + .as_ref() + .and_then(|h| h.get(b"host")) + .map(<[u8]>::to_vec); + let host: &[u8] = user_host.as_deref().unwrap_or(url.host); + let (service, region) = opts.scope_for(global, host, &creds)?; + let s3 = sigv4::is_s3_service(&service); + + let payload = match body { + Body::Bytes(b) if opts.unsigned_payload && s3 => { + let _ = b; + sigv4::Payload::Unsigned + } + Body::Bytes(b) => sigv4::Payload::Bytes(b), + Body::Streaming if s3 => sigv4::Payload::Unsigned, + Body::Streaming => return Err(SignFetchError::StreamingBody), + }; + + let mut pairs: Vec<(&[u8], &[u8])> = Vec::new(); + if let Some(h) = headers.as_ref() { + let entries = h.entries.slice(); + let names = entries.items_name(); + let values = entries.items_value(); + for (i, n) in names.iter().enumerate() { + let name = h.as_str(*n); + if RESERVED.iter().any(|r| name.eq_ignore_ascii_case(r)) { + return Err(SignFetchError::ReservedHeader(Box::from(name))); + } + if name.eq_ignore_ascii_case(b"host") { + continue; + } + pairs.push((name, h.as_str(values[i]))); + } + } + + let query = url.search(); + let query = query.strip_prefix(b"?".as_slice()).unwrap_or(query); + let req = sigv4::Request { + method: method.as_str().as_bytes(), + host, + path: url.raw_pathname(), + query, + headers: &pairs, + payload, + scope: sigv4::Scope { + service: &service, + region: ®ion, + }, + datetime: opts.datetime, + s3_path_semantics: None, + }; + let sig_creds = creds.sigv4(); + + if opts.sign_query { + let scheme = if url.is_https() { + b"https".as_slice() + } else { + b"http".as_slice() + }; + let signed = sigv4::presign(&sig_creds, &req, scheme, opts.expires_in) + .map_err(SignFetchError::Sign)?; + drop(pairs); + return Ok(Signed::Url(signed.url)); + } + + let signed = sigv4::sign(&sig_creds, &req).map_err(SignFetchError::Sign)?; + drop(pairs); + let h = headers.get_or_insert_with(Headers::default); + h.append(b"Authorization", &signed.authorization); + h.append(b"x-amz-date", &signed.amz_date); + if signed.send_content_sha256 { + h.append(b"x-amz-content-sha256", &signed.content_sha256); + } + if let Some(t) = creds.session_token() { + h.append(b"x-amz-security-token", t); + } + Ok(Signed::Headers) +} + +/// A JS `Error` for a credential-provider failure, with `.code`. +pub fn provider_error_to_js(global: &JSGlobalObject, err: &ProviderError) -> JSValue { + let value = global.create_error_instance(format_args!("{}", bstr::BStr::new(&err.message))); + match bun_core::String::init(err.code.as_bytes()).to_js(global) { + Ok(code) => value.put(global, b"code".as_slice(), code), + Err(e) => return global.take_error(e), + } + value +} diff --git a/src/runtime/webcore/cloud/aws/ini.rs b/src/runtime/webcore/cloud/aws/ini.rs new file mode 100644 index 000000000000..a6354a8c3d64 --- /dev/null +++ b/src/runtime/webcore/cloud/aws/ini.rs @@ -0,0 +1,180 @@ +//! `~/.aws/config` and `~/.aws/credentials`: `[section]` headers, +//! `key = value` lines, `#`/`;` comments, and indented continuation lines +//! (nested `s3 =` blocks) which are skipped. + +use bun_core::strings; + +pub struct Section { + /// `default`, a profile name, or `sso-session NAME` / `services NAME`. + pub kind: SectionKind, + pub name: Box<[u8]>, + pub entries: Vec<(Box<[u8]>, Box<[u8]>)>, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum SectionKind { + Profile, + SsoSession, + Other, +} + +#[derive(Default)] +pub struct IniFile { + pub sections: Vec
, +} + +impl IniFile { + /// `is_config`: in the config file profiles are spelled `[profile NAME]` + /// (except `[default]`); in the credentials file they are bare `[NAME]`. + pub fn parse(contents: &[u8], is_config: bool) -> IniFile { + let mut file = IniFile::default(); + let mut current: Option = None; + // Set by a `key =` line with no value: the indented lines that follow + // are its sub-properties (`s3 =\n max_concurrent_requests = 20`). + let mut in_subsection = false; + for raw_line in strings::split(contents, b"\n") { + let line = strings::trim(raw_line, b" \t\r"); + if line.is_empty() || line[0] == b'#' || line[0] == b';' { + continue; + } + let indented = raw_line.first().is_some_and(|c| *c == b' ' || *c == b'\t'); + if line[0] == b'[' { + current = None; + in_subsection = false; + let Some(end) = strings::index_of_char_usize(line, b']') else { + continue; + }; + let header = strings::trim(&line[1..end], b" \t"); + let (kind, name) = Self::classify(header, is_config); + if name.is_empty() { + continue; + } + // A later duplicate section merges into (and overrides) the earlier one. + current = Some( + match file + .sections + .iter() + .position(|s| s.kind == kind && &*s.name == name) + { + Some(i) => i, + None => { + file.sections.push(Section { + kind, + name: Box::from(name), + entries: Vec::new(), + }); + file.sections.len() - 1 + } + }, + ); + continue; + } + if indented && in_subsection { + continue; + } + in_subsection = false; + let Some(section) = current else { continue }; + let Some(eq) = strings::index_of_char_usize(line, b'=') else { + continue; + }; + let key = strings::trim(&line[..eq], b" \t"); + let mut value = strings::trim(&line[eq + 1..], b" \t"); + if value.is_empty() { + in_subsection = true; + continue; + } + // Inline comments as the AWS SDK for JavaScript v3 reads them + // (`(^|\s)[;#]`): `#`/`;` glued to the value (ARNs) stay. + // (botocore strips none; the JS SDK is the closer relative.) + for marker in [b" #", b" ;", b"\t#", b"\t;"] { + if let Some(i) = strings::index_of(value, marker) { + value = strings::trim(&value[..i], b" \t"); + } + } + if key.is_empty() { + continue; + } + let entries = &mut file.sections[section].entries; + let key_lower: Box<[u8]> = key.iter().map(u8::to_ascii_lowercase).collect(); + if let Some(existing) = entries.iter_mut().find(|(k, _)| *k == key_lower) { + existing.1 = Box::from(value); + } else { + entries.push((key_lower, Box::from(value))); + } + } + file + } + + fn classify(header: &[u8], is_config: bool) -> (SectionKind, &[u8]) { + let split_kw = |kw: &[u8]| -> Option<&[u8]> { + if header.len() > kw.len() + && header[..kw.len()].eq_ignore_ascii_case(kw) + && (header[kw.len()] == b' ' || header[kw.len()] == b'\t') + { + Some(strings::trim(&header[kw.len()..], b" \t")) + } else { + None + } + }; + if let Some(name) = split_kw(b"sso-session") { + return (SectionKind::SsoSession, name); + } + if split_kw(b"services").is_some() || split_kw(b"plugins").is_some() { + return (SectionKind::Other, header); + } + if is_config { + if let Some(name) = split_kw(b"profile") { + return (SectionKind::Profile, name); + } + if header == b"default" { + return (SectionKind::Profile, header); + } + // The CLI also accepts bare `[name]` in config for legacy files. + return (SectionKind::Profile, header); + } + (SectionKind::Profile, header) + } + + pub fn section(&self, kind: SectionKind, name: &[u8]) -> Option<&Section> { + self.sections + .iter() + .find(|s| s.kind == kind && &*s.name == name) + } +} + +impl Section { + pub fn get(&self, key: &[u8]) -> Option<&[u8]> { + self.entries + .iter() + .find(|(k, _)| &**k == key) + .map(|(_, v)| &**v) + .filter(|v| !v.is_empty()) + } +} + +/// A profile's merged view: the credentials file wins over the config file +/// for keys present in both (matching the AWS CLI/SDKs). +pub struct Profile<'a> { + credentials: Option<&'a Section>, + config: Option<&'a Section>, +} + +impl<'a> Profile<'a> { + pub fn lookup(name: &[u8], credentials: &'a IniFile, config: &'a IniFile) -> Option { + let p = Profile { + credentials: credentials.section(SectionKind::Profile, name), + config: config.section(SectionKind::Profile, name), + }; + if p.credentials.is_none() && p.config.is_none() { + None + } else { + Some(p) + } + } + + pub fn get(&self, key: &[u8]) -> Option<&'a [u8]> { + self.credentials + .and_then(|s| s.get(key)) + .or_else(|| self.config.and_then(|s| s.get(key))) + } +} diff --git a/src/runtime/webcore/cloud/aws/js.rs b/src/runtime/webcore/cloud/aws/js.rs new file mode 100644 index 000000000000..4cc80ffa2528 --- /dev/null +++ b/src/runtime/webcore/cloud/aws/js.rs @@ -0,0 +1,260 @@ +//! `Bun.AWSClient` (and `Bun.aws`, an instance with default options): +//! `fetch()`, `presign()`, `credentials()`. + +use std::sync::Arc; + +use bun_core::String as BunString; +use bun_http_jsc::method_jsc; +use bun_jsc::bun_string_jsc::create_utf8_for_js; +use bun_jsc::{CallFrame, JSGlobalObject, JSPromiseStrong, JSValue, JsResult, StringJsc as _}; +use bun_s3_signing::AwsCredentials; +use bun_s3_signing::sigv4; + +use super::fetch_signing::provider_error_to_js; +use super::sign_options::{AwsSignOptions, Credentials}; +use crate::webcore::cloud::flight; +use crate::webcore::fetch::{FetchAuth, fetch_with_auth}; + +/// A set of AWS request-signing defaults: credentials (static, a profile, or +/// the ambient chain), region, service, endpoint. `Bun.aws` is one with no +/// overrides; `new Bun.AWSClient({...})` makes more. +#[bun_jsc::JsClass] +pub struct AWSClient { + pub(crate) options: Arc, +} + +impl AWSClient { + pub(crate) fn constructor(global: &JSGlobalObject, frame: &CallFrame) -> JsResult> { + let arg = frame + .arguments() + .first() + .copied() + .unwrap_or(JSValue::UNDEFINED); + Ok(Box::new(AWSClient { + options: Arc::new(AwsSignOptions::ambient().with_overrides(global, &[arg])?), + })) + } + + /// `Bun.aws`. + pub fn default(_global: &JSGlobalObject) -> JsResult> { + Ok(Box::new(AWSClient { + options: Arc::new(AwsSignOptions::ambient()), + })) + } + + /// `client.fetch(input, init?)` — `fetch()` with the request SigV4-signed + /// using this client's defaults overlaid with `init`. + #[bun_jsc::host_fn(method)] + pub(crate) fn fetch( + this: &Self, + global: &JSGlobalObject, + frame: &CallFrame, + ) -> JsResult { + fetch_with_auth(global, frame, FetchAuth::Aws(Arc::clone(&this.options))) + } + + /// `client.credentials({ refresh? })` → `Promise` + #[bun_jsc::host_fn(method)] + pub(crate) fn credentials( + this: &Self, + global: &JSGlobalObject, + frame: &CallFrame, + ) -> JsResult { + let arg = frame + .arguments() + .first() + .copied() + .unwrap_or(JSValue::UNDEFINED); + let options = this.options.with_overrides(global, &[arg])?; + let refresh = + arg.is_object() && arg.get_boolean_strict(global, "refresh")?.unwrap_or(false); + if refresh && let Credentials::Provider(p) = &options.credentials { + p.mark_stale(); + } + with_credentials(global, options.credentials, |global, creds| { + credentials_to_js(global, creds) + }) + } + + /// `client.presign(url, { expiresIn?, method?, ...overrides })` → `Promise` + #[bun_jsc::host_fn(method)] + pub(crate) fn presign( + this: &Self, + global: &JSGlobalObject, + frame: &CallFrame, + ) -> JsResult { + let args = frame.arguments(); + let Some(url_value) = args.first().copied() else { + return Err(global.throw_invalid_arguments(format_args!("presign() expects a URL"))); + }; + let options_value = args.get(1).copied().unwrap_or(JSValue::UNDEFINED); + let opts = this.options.with_overrides(global, &[options_value])?; + + let href = if url_value.is_string() { + BunString::from_js(url_value, global)? + } else { + bun_jsc::URL::href_from_js(url_value, global)? + }; + let href = bun_core::OwnedString::new(href); + let href_bytes: Vec = href.to_utf8().slice().to_vec(); + if !href_bytes.starts_with(b"/") { + let url = bun_url::URL::parse(&href_bytes); + if !(url.is_http() || url.is_https()) || url.host.is_empty() { + return Err(global.throw_invalid_arguments(format_args!( + "presign() expects an http: or https: URL, or a path with the service option" + ))); + } + } + let mut method = bun_http::Method::GET; + if options_value.is_object() + && let Some(m) = options_value.get_truthy(global, "method")? + { + method = match method_jsc::from_js(global, m)? { + Some(m) => m, + None => { + return Err(global.throw_invalid_arguments(format_args!( + "presign() method must be a valid HTTP method" + ))); + } + }; + } + + with_credentials(global, opts.credentials.clone(), move |global, creds| { + let mut href_bytes = href_bytes; + if href_bytes.starts_with(b"/") { + match opts.default_endpoint(global) { + Ok(mut origin) => { + origin.extend_from_slice(&href_bytes); + href_bytes = origin; + } + Err(e) => { + return Err(global.throw_invalid_arguments(format_args!("presign() {e}"))); + } + } + } + let url = bun_url::URL::parse(&href_bytes); + if !(url.is_http() || url.is_https()) || url.host.is_empty() { + return Err(global.throw_invalid_arguments(format_args!( + "presign() expects an http: or https: URL" + ))); + } + let (service, region) = match opts.scope_for(global, url.host, creds) { + Ok(v) => v, + Err(message) => { + return Err(global.throw_invalid_arguments(format_args!("{message}"))); + } + }; + let query = url.search(); + let query = query.strip_prefix(b"?".as_slice()).unwrap_or(query); + let signed = sigv4::presign( + &creds.sigv4(), + &sigv4::Request { + method: method.as_str().as_bytes(), + host: url.host, + path: url.raw_pathname(), + query, + headers: &[], + payload: if opts.unsigned_payload || sigv4::is_s3_service(&service) { + sigv4::Payload::Unsigned + } else { + sigv4::Payload::Bytes(b"") + }, + scope: sigv4::Scope { + service: &service, + region: ®ion, + }, + datetime: opts.datetime, + s3_path_semantics: None, + }, + if url.is_https() { b"https" } else { b"http" }, + opts.expires_in, + ); + match signed { + Ok(p) => create_utf8_for_js(global, &p.url), + Err(e) => Err(global.throw(format_args!("presign() failed: {e:?}"))), + } + }) + } + + #[bun_jsc::host_fn(getter)] + pub(crate) fn get_region(this: &Self, global: &JSGlobalObject) -> JsResult { + match this.options.configured_region(global) { + Some(r) => create_utf8_for_js(global, &r), + None => Ok(JSValue::UNDEFINED), + } + } + + #[bun_jsc::host_fn(getter)] + pub(crate) fn get_profile(this: &Self, global: &JSGlobalObject) -> JsResult { + match this.options.profile_label() { + Some(p) => create_utf8_for_js(global, p), + None => Ok(JSValue::UNDEFINED), + } + } +} + +/// A promise for `build(credentials)`: settled now when the credentials are +/// static or cached, else once the client's provider has resolved them. A +/// JS exception `build` leaves pending becomes the rejection. +fn with_credentials( + global: &JSGlobalObject, + credentials: Credentials, + build: impl FnOnce(&JSGlobalObject, &AwsCredentials) -> JsResult + 'static, +) -> JsResult { + match credentials { + Credentials::Static(c) => { + let mut promise = JSPromiseStrong::init(global); + let value = promise.value(); + let built = build(global, &c); + promise.settle(global, built)?; + Ok(value) + } + Credentials::Provider(provider) => { + flight::promise(global, &provider, provider_error_to_js, build) + } + } +} + +pub fn credentials_to_js(global: &JSGlobalObject, c: &AwsCredentials) -> JsResult { + let obj = JSValue::create_empty_object(global, 7); + obj.put( + global, + b"accessKeyId".as_slice(), + create_utf8_for_js(global, &c.access_key_id)?, + ); + obj.put( + global, + b"secretAccessKey".as_slice(), + create_utf8_for_js(global, &c.secret_access_key)?, + ); + if let Some(t) = c.session_token() { + obj.put( + global, + b"sessionToken".as_slice(), + create_utf8_for_js(global, t)?, + ); + } + if let Some(exp) = c.expiration { + obj.put( + global, + b"expiration".as_slice(), + JSValue::from_date_number(global, exp as f64 * 1000.0), + ); + } + if let Some(r) = &c.region { + obj.put(global, b"region".as_slice(), create_utf8_for_js(global, r)?); + } + if let Some(a) = &c.account_id { + obj.put( + global, + b"accountId".as_slice(), + create_utf8_for_js(global, a)?, + ); + } + obj.put( + global, + b"source".as_slice(), + create_utf8_for_js(global, c.source.as_str().as_bytes())?, + ); + Ok(obj) +} diff --git a/src/runtime/webcore/cloud/aws/mod.rs b/src/runtime/webcore/cloud/aws/mod.rs new file mode 100644 index 000000000000..cad62446029c --- /dev/null +++ b/src/runtime/webcore/cloud/aws/mod.rs @@ -0,0 +1,14 @@ +//! The AWS default credential provider chain, SigV4 request signing for +//! `fetch()`, and `Bun.aws`. + +pub mod chain; +pub mod config; +pub mod fetch_signing; +pub mod ini; +pub mod js; +pub mod provider; +pub mod sign_options; + +pub use js::AWSClient; +pub use provider::{DefaultProvider, default_provider, resolve_shared_async}; +pub use sign_options::AwsSignOptions; diff --git a/src/runtime/webcore/cloud/aws/provider.rs b/src/runtime/webcore/cloud/aws/provider.rs new file mode 100644 index 000000000000..5a14f0ee9e7c --- /dev/null +++ b/src/runtime/webcore/cloud/aws/provider.rs @@ -0,0 +1,142 @@ +//! The cached AWS credential provider for one profile key; resolution, +//! waiting and background refresh are `cloud::flight`'s. + +use std::sync::Arc; + +use bun_jsc::virtual_machine::VirtualMachine; +use bun_jsc::{JSGlobalObject, JsResult}; +use bun_s3_signing::{AwsCredentials, CredentialsProvider, ProviderError, SharedProvider}; + +use super::chain; +use super::config::ChainConfig; +use crate::webcore::cloud::cache::CredentialCache; +use crate::webcore::cloud::flight::{self, Flights, Provider}; +use crate::webcore::cloud::io::{ChainFuture, Io}; + +/// The default chain for one profile key (`None` = whatever `AWS_PROFILE` +/// says at resolution time). +pub struct DefaultProvider { + profile: Option>, + label: Box<[u8]>, + cache: CredentialCache, +} + +impl DefaultProvider { + pub fn profile(&self) -> Option<&[u8]> { + self.profile.as_deref() + } + + pub fn mark_stale(&self) { + self.cache.mark_stale() + } + + /// The message for a synchronous API that finds these credentials + /// neither cached nor obtainable without waiting. + pub fn pending_message(&self) -> Vec { + let label = bstr::BStr::new(&self.label); + let how = if self.profile.is_none() { + "Bun.aws.credentials()".to_string() + } else { + format!("new Bun.AWSClient({{ profile: {label:?} }}).credentials()") + }; + if self.cache.has_expired_value() { + format!( + "AWS credentials for profile \"{label}\" have expired and their replacement has not arrived yet in this synchronous call; `await {how}` (or any asynchronous S3 operation) first" + ) + } else { + format!( + "AWS credentials for profile \"{label}\" come from a source that needs a network round-trip (SSO, STS, a container endpoint or instance metadata) and have not been resolved yet in this synchronous call; `await {how}` (or any asynchronous S3 operation) first, or pass accessKeyId and secretAccessKey" + ) + } + .into_bytes() + } +} + +impl Provider for DefaultProvider { + type Value = AwsCredentials; + + fn cache(&self) -> &CredentialCache { + &self.cache + } + + fn begin(&self, global: &JSGlobalObject, io: Io) -> ChainFuture { + chain::resolve(ChainConfig::capture(global, self.profile()), io) + } + + fn flights() -> &'static mut Flights { + &mut crate::webcore::cloud::PerVm::get(VirtualMachine::get()).aws + } + + fn interrupted() -> ProviderError { + ProviderError::new( + "ERR_AWS_CREDENTIALS", + b"credential resolution was interrupted because the JavaScript VM is shutting down" + .to_vec(), + ) + } +} + +impl CredentialsProvider for DefaultProvider { + /// Whatever is cached and not past expiry, even if inside the refresh + /// window — requests keep being served while a refresh is in flight. + fn cached(&self) -> Option> { + self.cache.usable() + } + + fn needs_resolution(&self) -> bool { + if self.cache.usable().is_none() { + return true; + } + if VirtualMachine::is_loaded() + && let Some(this) = + DefaultProvider::flights().by_address(std::ptr::from_ref(self).cast()) + { + flight::keep_warm(VirtualMachine::get().global(), &this); + } + false + } + + fn label(&self) -> &[u8] { + &self.label + } +} + +/// The shared provider for `profile` (`None` = default) in this VM (a +/// Worker with its own env gets its own). +pub fn default_provider(profile: Option<&[u8]>) -> Arc { + let flights = DefaultProvider::flights(); + if let Some(p) = flights.find(|p| p.profile() == profile) { + return p; + } + let profile: Option> = profile.map(Box::from); + flights.insert(DefaultProvider { + label: profile + .clone() + .unwrap_or_else(|| Box::from(b"default".as_slice())), + profile, + cache: CredentialCache::new(AwsCredentials::REFRESH_WINDOW_SECONDS), + }) +} + +/// `S3Credentials` holds a type-erased `SharedProvider`; every one of those +/// is a `DefaultProvider` from this VM's registry, so recover it by identity. +pub fn as_default(provider: &SharedProvider) -> Option> { + DefaultProvider::flights().by_address(Arc::as_ptr(provider).cast()) +} + +/// [`flight::resolve_async`] for the type-erased handle `S3Credentials` carries. +pub fn resolve_shared_async( + global: &JSGlobalObject, + provider: &SharedProvider, + then: flight::Continuation, +) -> JsResult<()> { + match as_default(provider) { + Some(p) => flight::resolve_async(global, &p, then), + None => then(provider.cached().ok_or_else(|| { + Arc::new(ProviderError::new( + "ERR_AWS_MISSING_CREDENTIALS", + b"credentials provider is not registered with this JavaScript VM".to_vec(), + )) + })), + } +} diff --git a/src/runtime/webcore/cloud/aws/sign_options.rs b/src/runtime/webcore/cloud/aws/sign_options.rs new file mode 100644 index 000000000000..24c36db90d73 --- /dev/null +++ b/src/runtime/webcore/cloud/aws/sign_options.rs @@ -0,0 +1,390 @@ +//! Options shared by `new Bun.AWSClient(...)`, `client.fetch(url, init)` and +//! `client.presign(...)`: which credentials to sign with, for what +//! service/region, and how. + +use std::sync::Arc; + +use bstr::BStr; +use bun_core::strings; +use bun_jsc::{JSGlobalObject, JSValue, JsResult}; +use bun_s3_signing::sigv4; +use bun_s3_signing::{AwsCredentials, CredentialsProvider as _, CredentialsSource}; + +use super::DefaultProvider; + +use crate::webcore::s3::credentials_jsc::get_truthy_string_utf8; + +#[derive(Clone)] +pub enum Credentials { + Static(Arc), + Provider(Arc), +} + +#[derive(Clone)] +pub struct AwsSignOptions { + pub credentials: Credentials, + pub service: Option>, + pub region: Option>, + /// Sign with `UNSIGNED-PAYLOAD` instead of hashing the body (S3 only). + pub unsigned_payload: bool, + /// Put the signature in the query string instead of headers. + pub sign_query: bool, + pub expires_in: u32, + /// Test hook / reproducible signatures: `YYYYMMDDTHHMMSSZ`. + pub datetime: Option<[u8; 16]>, + /// Base URL for path-only requests (e.g. LocalStack); otherwise the + /// service's standard endpoint is used. + pub endpoint: Option>, +} + +/// `AWS_REGION` (or `AWS_DEFAULT_REGION`) right now. +fn env_region(global: &JSGlobalObject) -> Option> { + let env = crate::webcore::cloud::env::Env::new(global); + env.get(b"AWS_REGION") + .or_else(|| env.get(b"AWS_DEFAULT_REGION")) + .filter(|s| !s.is_empty()) + .map(Vec::into_boxed_slice) +} + +fn contains_crlf(s: &[u8]) -> bool { + strings::index_of_any(s, b"\r\n").is_some() +} + +impl AwsSignOptions { + /// No overrides: ambient credentials, everything else inferred. + pub fn ambient() -> Self { + AwsSignOptions { + credentials: Credentials::Provider(super::default_provider(None)), + service: None, + region: None, + unsigned_payload: false, + sign_query: false, + expires_in: 900, + datetime: None, + endpoint: None, + } + } + + /// These options with the fields of each of `values` (options objects, + /// or `undefined`/`null` for none) laid over them in turn. + pub fn with_overrides(&self, global: &JSGlobalObject, values: &[JSValue]) -> JsResult { + let mut out = self.clone(); + for value in values { + if value.is_undefined_or_null() { + continue; + } + if !value.is_object() { + return Err(global.throw_invalid_arguments(format_args!( + "expected an options object like {{ region, profile, service, accessKeyId, secretAccessKey }}" + ))); + } + out.apply(global, *value)?; + } + Ok(out) + } + + /// Overlay the fields present in `value`. + fn apply(&mut self, global: &JSGlobalObject, value: JSValue) -> JsResult<()> { + let out = self; + if let Some(profile) = get_truthy_string_utf8(value, global, b"profile", true)? { + out.credentials = Credentials::Provider(super::default_provider(Some(profile.slice()))); + } + let access_key_id = get_truthy_string_utf8(value, global, b"accessKeyId", true)?; + let secret_access_key = get_truthy_string_utf8(value, global, b"secretAccessKey", true)?; + let session_token = get_truthy_string_utf8(value, global, b"sessionToken", true)?; + match (access_key_id, secret_access_key) { + (Some(a), Some(s)) => { + if contains_crlf(a.slice()) + || session_token + .as_ref() + .is_some_and(|t| contains_crlf(t.slice())) + { + return Err(global.throw_invalid_arguments(format_args!( + "AWS credentials must not contain newline characters" + ))); + } + out.credentials = Credentials::Static(Arc::new(AwsCredentials { + access_key_id: Box::from(a.slice()), + secret_access_key: Box::from(s.slice()), + session_token: session_token + .map(|t| Box::from(t.slice())) + .unwrap_or_default(), + expiration: None, + account_id: None, + region: None, + source: CredentialsSource::Explicit, + })); + } + (Some(_), None) | (None, Some(_)) => { + return Err(global.throw_invalid_arguments(format_args!( + "accessKeyId and secretAccessKey must be given together" + ))); + } + (None, None) => { + if session_token.is_some() { + return Err(global.throw_invalid_arguments(format_args!( + "sessionToken requires accessKeyId and secretAccessKey" + ))); + } + } + } + if let Some(service) = get_truthy_string_utf8(value, global, b"service", true)? { + let s = service.slice(); + if !s + .iter() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.')) + { + return Err(global.throw_invalid_arguments(format_args!( + "service \"{}\" is not a valid AWS service name", + BStr::new(s) + ))); + } + out.service = Some(s.iter().map(u8::to_ascii_lowercase).collect()); + } + if let Some(region) = get_truthy_string_utf8(value, global, b"region", true)? { + let r = region.slice(); + if !r + .iter() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'*')) + { + return Err(global.throw_invalid_arguments(format_args!( + "region \"{}\" is not a valid AWS region", + BStr::new(r) + ))); + } + out.region = Some(r.iter().map(u8::to_ascii_lowercase).collect()); + } + if let Some(b) = value.get_boolean_strict(global, "unsignedPayload")? { + out.unsigned_payload = b; + } + if let Some(b) = value.get_boolean_strict(global, "signQuery")? { + out.sign_query = b; + } + if let Some(n) = value.get_optional::(global, "expiresIn")? { + if n <= 0 || n as u32 > sigv4::MAX_PRESIGN_EXPIRES { + return Err(global.throw_range_error( + i64::from(n), + bun_jsc::RangeErrorOptions { + min: 1, + max: i64::from(sigv4::MAX_PRESIGN_EXPIRES), + field_name: b"expiresIn", + ..Default::default() + }, + )); + } + out.expires_in = n as u32; + } + if let Some(d) = get_truthy_string_utf8(value, global, b"signingDate", false)? { + // Accept an `x-amz-date`-formatted string for reproducible signatures. + let d = d.slice(); + if d.len() == 16 && sigv4::parse_iso8601(d).is_some() { + let mut buf = [0u8; 16]; + buf.copy_from_slice(d); + out.datetime = Some(buf); + } else { + return Err(global.throw_invalid_arguments(format_args!( + "signingDate must look like 20250101T000000Z" + ))); + } + } else if let Some(v) = value.get_truthy(global, "signingDate")? { + let ms = if v.is_number() { + v.as_number() + } else if v.is_date() { + v.get_unix_timestamp() + } else { + f64::NAN + }; + // Up to 9999-12-31T23:59:59Z, which is all `x-amz-date` can spell. + if !(ms.is_finite() && (0.0..253_402_300_800_000.0).contains(&ms)) { + return Err(global.throw_invalid_arguments(format_args!( + "signingDate must be a Date, epoch milliseconds, or a string like 20250101T000000Z" + ))); + } + out.datetime = Some(sigv4::amz_datetime((ms / 1000.0) as u64)); + } + if let Some(endpoint) = get_truthy_string_utf8(value, global, b"endpoint", true)? { + let e = endpoint.slice(); + let parsed = bun_url::URL::parse(e); + if !(parsed.is_http() || parsed.is_https()) || parsed.host.is_empty() { + return Err(global.throw_invalid_arguments(format_args!( + "endpoint must be an http:// or https:// URL" + ))); + } + out.endpoint = Some(Box::from(bun_core::strings::trim_right(e, b"/"))); + } + Ok(()) + } + + /// `https://{service}.{region}.amazonaws.com` (or the partition / + /// global-service equivalent) for `Bun.aws.fetch("/path", { service })`. + pub fn default_endpoint(&self, global: &JSGlobalObject) -> Result, EndpointError> { + if let Some(e) = &self.endpoint { + return Ok(e.to_vec()); + } + let Some(service) = self.service.as_deref() else { + return Err(EndpointError::NoService); + }; + // Endpoint host label, where it differs from the signing name. + let host_label: &[u8] = match service { + b"ses" => b"email", + b"iotdata" => b"data-ats.iot", + b"execute-api" | b"lambda" | b"es" | b"aoss" => { + return Err(EndpointError::NeedsHost(Box::from(service))); + } + other => other, + }; + // Services with a single global endpoint (signed as us-east-1). + const GLOBAL: &[&[u8]] = &[ + b"iam", + b"cloudfront", + b"route53", + b"globalaccelerator", + b"organizations", + b"shield", + b"waf", + b"importexport", + b"networkmanager", + ]; + let region: Option> = if GLOBAL.contains(&service) { + None + } else { + match self.region.clone().or_else(|| env_region(global)) { + Some(r) => Some(r), + None => match &self.credentials { + Credentials::Static(c) => c.region.clone(), + Credentials::Provider(p) => match p.cached() { + Some(c) => c.region.clone(), + None if p.needs_resolution() => { + return Err(EndpointError::RegionPending(Arc::clone(p))); + } + None => None, + }, + }, + } + .map(Some) + .ok_or(EndpointError::NoRegion)? + }; + let suffix = match region.as_deref() { + Some(r) => super::chain::dns_suffix(r), + None => "amazonaws.com", + }; + Ok(match region { + Some(r) => format!( + "https://{}.{}.{suffix}", + BStr::new(host_label), + BStr::new(&r) + ), + None => format!("https://{}.{suffix}", BStr::new(host_label)), + } + .into_bytes()) + } + + pub fn provider(&self) -> Option<&Arc> { + match &self.credentials { + Credentials::Provider(p) => Some(p), + Credentials::Static(_) => None, + } + } + + pub fn needs_credentials_resolution(&self) -> bool { + self.provider().is_some_and(|p| p.needs_resolution()) + } + + /// Static or already-resolved credentials. Asynchronous callers resolve + /// the provider first, so `None` here means a caller skipped that. + pub fn available_credentials(&self) -> Option> { + match &self.credentials { + Credentials::Static(c) => Some(Arc::clone(c)), + Credentials::Provider(p) => p.cached(), + } + } + + /// `(service, region)` for `host`, filling gaps from the hostname and + /// the environment. Errors name what is missing. + pub fn scope_for( + &self, + global: &JSGlobalObject, + host: &[u8], + creds: &AwsCredentials, + ) -> Result<(Box<[u8]>, Box<[u8]>), ScopeError> { + let (inferred_service, inferred_region) = if self.service.is_none() || self.region.is_none() + { + sigv4::infer_service_region(host) + } else { + (None, None) + }; + let service = self + .service + .clone() + .or(inferred_service) + .ok_or_else(|| ScopeError::UnknownService(Box::from(host)))?; + let region = self + .region + .clone() + .or(inferred_region) + .or_else(|| env_region(global)) + .or_else(|| creds.region.clone()) + .ok_or_else(|| ScopeError::UnknownRegion(Box::from(host)))?; + Ok((service, region)) + } +} + +/// Which part of the signing scope could not be worked out for a host. +#[derive(Debug, thiserror::Error)] +pub enum ScopeError { + #[error("cannot tell which AWS service \"{}\" is; pass service: \"...\"", BStr::new(.0))] + UnknownService(Box<[u8]>), + #[error( + "cannot tell which AWS region \"{}\" is in; pass region: \"...\" or set AWS_REGION", + BStr::new(.0) + )] + UnknownRegion(Box<[u8]>), +} + +pub enum EndpointError { + NoService, + NoRegion, + NeedsHost(Box<[u8]>), + /// The region may come with the credentials, which are not resolved yet. + RegionPending(Arc), +} + +impl core::fmt::Display for EndpointError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + EndpointError::NoService => { + f.write_str("needs `service` (e.g. \"sqs\") to build a URL from a relative path") + } + EndpointError::NoRegion | EndpointError::RegionPending(_) => f.write_str( + "cannot tell which region to use for a relative path; pass `region` or set AWS_REGION", + ), + EndpointError::NeedsHost(svc) => write!( + f, + "\"{}\" endpoints are per-resource; pass the full https:// URL", + BStr::new(svc) + ), + } + } +} + +impl AwsSignOptions { + /// Explicit region, else `AWS_REGION`, else the region already known from + /// resolved/static credentials. + pub fn configured_region(&self, global: &JSGlobalObject) -> Option> { + self.region + .clone() + .or_else(|| env_region(global)) + .or_else(|| match &self.credentials { + Credentials::Static(c) => c.region.clone(), + Credentials::Provider(p) => p.cached().and_then(|c| c.region.clone()), + }) + } + + /// The profile the credentials come from, if they are ambient. + pub fn profile_label(&self) -> Option<&[u8]> { + match &self.credentials { + Credentials::Provider(p) => Some(p.label()), + Credentials::Static(_) => None, + } + } +} diff --git a/src/runtime/webcore/cloud/cache.rs b/src/runtime/webcore/cloud/cache.rs new file mode 100644 index 000000000000..cc7cb9c5c5b0 --- /dev/null +++ b/src/runtime/webcore/cloud/cache.rs @@ -0,0 +1,206 @@ +//! What a credential provider remembers between resolutions: the last good +//! value (served until it expires, refreshed a little before), and the last +//! failure (served for a few seconds so a burst of callers does not re-run a +//! failing chain back to back). Scheduling — who resolves, who waits — lives +//! with the per-VM provider state; this is just the memory, behind a mutex so +//! the provider can sit in `Send + Sync` handles. + +use std::sync::Arc; + +use bun_s3_signing::ProviderError; +use bun_threading::Guarded; + +pub trait Expiring { + /// Unix epoch seconds; `None` never expires. + fn expiration(&self) -> Option; +} + +impl Expiring for bun_s3_signing::AwsCredentials { + fn expiration(&self) -> Option { + self.expiration + } +} + +pub fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +struct State { + cached: Option>, + /// When `cached` was obtained. A value counts as fresh for at least + /// `MIN_REFRESH_INTERVAL` after that even if it was issued already inside + /// the refresh window, so short-lived credentials do not refresh back to back. + resolved_at: u64, + last_error: Option<(Arc, u64)>, + /// A consumer read the value since the last `settle`. + used: bool, + /// `refresh: true`: the value no longer counts as fresh (but stays + /// usable) until the next `settle`. + stale: bool, +} + +pub struct CredentialCache { + state: Guarded>, + /// Refresh this many seconds before expiry. + refresh_window: u64, +} + +/// Credentials this close to expiry are treated as expired (clock skew / +/// request latency margin). +const EXPIRY_MARGIN: u64 = bun_s3_signing::AwsCredentials::EXPIRY_MARGIN_SECONDS; +/// A failure is remembered (and returned without retrying) for this long. +const NEGATIVE_TTL: u64 = 3; +/// See `State::resolved_at`. +pub const MIN_REFRESH_INTERVAL: u64 = 60; + +impl CredentialCache { + pub const fn new(refresh_window: u64) -> Self { + Self { + state: Guarded::new(State { + cached: None, + resolved_at: 0, + last_error: None, + used: false, + stale: false, + }), + refresh_window, + } + } + + fn is_fresh(&self, st: &State, v: &V, now: u64) -> bool { + !st.stale + && v.expiration().is_none_or(|e| { + e > now + self.refresh_window + || (now < st.resolved_at + MIN_REFRESH_INTERVAL && e > now + EXPIRY_MARGIN) + }) + } + + fn is_usable(v: &V, now: u64) -> bool { + v.expiration().is_none_or(|e| e > now + EXPIRY_MARGIN) + } + + /// How long from now until `v` should be refreshed in the background: + /// when it enters the refresh window, or for a short-lived value halfway + /// through what is left. `None` if it never expires. + pub fn refresh_delay_ms(&self, v: &V) -> Option { + let e = v.expiration()?; + let now = now_secs(); + let secs = if e > now + self.refresh_window + MIN_REFRESH_INTERVAL { + e - self.refresh_window - now + } else { + (e.saturating_sub(now + EXPIRY_MARGIN) / 2).max(1) + }; + Some(secs.saturating_mul(1000)) + } + + /// The cached value's expiration and whether anyone read it since the + /// last `settle` (resetting that flag). + pub fn take_usage(&self) -> (Option, bool) { + let mut st = self.state.lock(); + let used = core::mem::take(&mut st.used); + (st.cached.as_ref().and_then(|v| v.expiration()), used) + } + + /// The cached value if not yet expired, without counting as a use. + pub fn peek(&self) -> Option> { + let st = self.state.lock(); + let now = now_secs(); + st.cached + .as_ref() + .filter(|v| Self::is_usable(v, now)) + .cloned() + } + + /// Cached and outside the refresh window. + pub fn fresh(&self) -> Option> { + let mut st = self.state.lock(); + let now = now_secs(); + let v = st + .cached + .as_ref() + .filter(|v| self.is_fresh(&st, v, now)) + .cloned(); + st.used |= v.is_some(); + v + } + + /// Cached and not yet expired (may be inside the refresh window) — good + /// enough to sign with while a refresh is in flight. + pub fn usable(&self) -> Option> { + let mut st = self.state.lock(); + let now = now_secs(); + let v = st + .cached + .as_ref() + .filter(|v| Self::is_usable(v, now)) + .cloned(); + st.used |= v.is_some(); + v + } + + /// A value is cached but has expired (as opposed to never resolved). + pub fn has_expired_value(&self) -> bool { + let st = self.state.lock(); + let now = now_secs(); + st.cached.as_ref().is_some_and(|v| !Self::is_usable(v, now)) + } + + /// The error of a resolution that failed less than `NEGATIVE_TTL` ago. + pub fn recent_error(&self) -> Option> { + let st = self.state.lock(); + let now = now_secs(); + st.last_error + .as_ref() + .filter(|(_, at)| now < at + NEGATIVE_TTL) + .map(|(e, _)| Arc::clone(e)) + } + + /// The last resolution's error, however old (cleared by a success). + pub fn last_error(&self) -> Option> { + self.state + .lock() + .last_error + .as_ref() + .map(|(e, _)| Arc::clone(e)) + } + + /// `refresh: true`: keep serving the value to callers that cannot wait, + /// but make everyone who can wait resolve anew. + pub fn mark_stale(&self) { + let mut st = self.state.lock(); + st.stale = true; + st.last_error = None; + } + + /// Record a finished resolution and return what callers should now get: + /// the new value, or — if it failed but the old value has not actually + /// expired — the old value. + pub fn settle(&self, result: Result) -> Result, Arc> { + let mut st = self.state.lock(); + let now = now_secs(); + match result { + Ok(v) => { + let v = Arc::new(v); + st.cached = Some(Arc::clone(&v)); + st.resolved_at = now; + st.last_error = None; + st.stale = false; + Ok(v) + } + Err(e) => { + let e = Arc::new(e); + st.last_error = Some((Arc::clone(&e), now)); + // An explicit refresh that failed says so; a background one + // keeps serving the old value while it lasts. + let forced = core::mem::take(&mut st.stale); + match &st.cached { + Some(v) if !forced && Self::is_usable(v, now) => Ok(Arc::clone(v)), + _ => Err(e), + } + } + } + } +} diff --git a/src/runtime/webcore/cloud/env.rs b/src/runtime/webcore/cloud/env.rs new file mode 100644 index 000000000000..006efef4f796 --- /dev/null +++ b/src/runtime/webcore/cloud/env.rs @@ -0,0 +1,114 @@ +//! Reads configuration from the live `process.env` object. + +use bun_jsc::{ + JSGlobalObject, JSPropertyIterator, JSPropertyIteratorOptions, JSValue, StringJsc as _, +}; + +/// Reads the live `process.env` object (so `process.env.AWS_PROFILE = "x"` +/// at runtime is honoured), falling back to the VM's dotenv loader if the +/// object is unavailable. Getter exceptions are swallowed as "unset". +pub struct Env<'a> { + global: &'a JSGlobalObject, + object: Option, +} + +impl<'a> Env<'a> { + pub fn new(global: &'a JSGlobalObject) -> Self { + let object = global + .to_js_value() + .get(global, "process") + .ok() + .flatten() + .filter(|p| p.is_object()) + .and_then(|p| p.get(global, "env").ok().flatten()) + .filter(|e| e.is_object()); + if object.is_none() { + global.clear_exception_except_termination(); + } + Env { global, object } + } + + pub fn get(&self, key: &[u8]) -> Option> { + match self.object { + Some(obj) => match obj.get(self.global, key) { + Ok(Some(v)) if v.is_string() => { + let s = + bun_core::OwnedString::new(bun_core::String::from_js(v, self.global).ok()?); + Some(s.to_utf8().slice().to_vec()) + } + Ok(_) => None, + Err(_) => { + self.global.clear_exception_except_termination(); + None + } + }, + None => self + .global + .bun_vm() + .as_mut() + .transpiler + .env_mut() + .get(key) + .map(<[u8]>::to_vec), + } + } + + /// `lower` then `upper`, treating an empty or `""`/`''` value as unset — + /// the same rules `fetch()` applies to `http(s)_proxy` (CI images often + /// export `https_proxy=""` as a default). + pub fn get_proxy_var(&self, lower: &[u8], upper: &[u8]) -> Option> { + let emptyish = |v: &[u8]| v.is_empty() || v == b"\"\"" || v == b"''"; + self.get(lower) + .filter(|v| !emptyish(v)) + .or_else(|| self.get(upper).filter(|v| !emptyish(v))) + } + + /// Every string-valued entry, for `credential_process` children. + pub fn to_map(&self) -> bun_sys::EnvMap { + let vm = self.global.bun_vm().as_mut(); + let from_loader = || { + vm.transpiler + .env_mut() + .map + .std_env_map() + .map(|w| w.get().clone()) + .unwrap_or_default() + }; + let Some(obj) = self.object.and_then(JSValue::get_object) else { + return from_loader(); + }; + let mut map = bun_sys::EnvMap::default(); + let Ok(mut iter) = + JSPropertyIterator::init(self.global, obj, JSPropertyIteratorOptions::new(true, true)) + else { + self.global.clear_exception_except_termination(); + return from_loader(); + }; + loop { + match iter.next() { + Ok(Some(key)) => { + let value = iter.value; + if !value.is_string() { + continue; + } + let Ok(v) = bun_core::String::from_js(value, self.global) else { + self.global.clear_exception_except_termination(); + continue; + }; + let v = bun_core::OwnedString::new(v); + #[allow(clippy::disallowed_methods)] + map.insert( + key.to_string(), + String::from_utf8_lossy(v.to_utf8().slice()).into_owned(), + ); + } + Ok(None) => break, + Err(_) => { + self.global.clear_exception_except_termination(); + break; + } + } + } + map + } +} diff --git a/src/runtime/webcore/cloud/flight.rs b/src/runtime/webcore/cloud/flight.rs new file mode 100644 index 000000000000..a494fbaa3a71 --- /dev/null +++ b/src/runtime/webcore/cloud/flight.rs @@ -0,0 +1,371 @@ +//! Single-flight credential resolution, shared by both clouds: per VM, one +//! chain runs per provider at a time, everyone who asks meanwhile waits on +//! it, the outcome lands in the provider's [`CredentialCache`], and a timer +//! refreshes it in the background before it expires. + +use std::sync::Arc; + +use bun_jsc::virtual_machine::VirtualMachine; +use bun_jsc::{GlobalRef, JSGlobalObject, JSPromiseStrong, JSValue, JsResult}; +use bun_s3_signing::ProviderError; + +use super::cache::{CredentialCache, Expiring, MIN_REFRESH_INTERVAL, now_secs}; +use super::io::{self, ChainFuture, Io}; +use crate::timer::CallbackTimer; + +pub type FlightResult = Result, Arc>; + +/// What to do with the value once it arrives (JS thread). Always called +/// exactly once, so whatever it owns (promises, request contexts) is released. +pub type Continuation = Box) -> JsResult<()>>; + +/// A cached source of one kind of credential (AWS credentials for a profile, +/// a Google token for a scope set …). +pub trait Provider: Sized + 'static { + type Value: Expiring + 'static; + + fn cache(&self) -> &CredentialCache; + + /// Snapshot whatever configuration the chain needs from `global` and + /// return the chain, doing its I/O through `io`. + fn begin( + &self, + global: &JSGlobalObject, + io: Io, + ) -> ChainFuture>; + + /// This VM's providers of this kind and their in-flight resolutions. + fn flights() -> &'static mut Flights; + + /// The error for a resolution cut short by the VM going away. + fn interrupted() -> ProviderError; +} + +struct Entry { + provider: Arc

, + /// `Some` while a chain is running: whom to tell when it lands. + waiters: Option>>, + /// Held while a waiter that must be answered (a promise, a request) is + /// registered, so the process stays up for it; background refreshes and + /// synchronous probes do not hold it. + keep_alive: bun_io::KeepAlive, + refresh_timer: Option>, +} + +impl Entry

{ + fn idle(&self) -> bool { + self.waiters.is_none() && self.refresh_timer.is_none() + } +} + +/// One VM's providers of kind `P`. +pub struct Flights { + entries: Vec>, +} + +impl Default for Flights

{ + fn default() -> Self { + Self { + entries: Vec::new(), + } + } +} + +/// Past this many providers, ones nothing references any more are dropped +/// on the next insert. +const EVICT_THRESHOLD: usize = 16; + +impl Flights

{ + pub fn find(&self, pred: impl Fn(&P) -> bool) -> Option> { + self.entries + .iter() + .find(|e| pred(&e.provider)) + .map(|e| Arc::clone(&e.provider)) + } + + pub fn insert(&mut self, provider: P) -> Arc

{ + if self.entries.len() >= EVICT_THRESHOLD { + self.evict(); + } + let provider = Arc::new(provider); + self.entries.push(Entry { + provider: Arc::clone(&provider), + waiters: None, + keep_alive: bun_io::KeepAlive::init(), + refresh_timer: None, + }); + provider + } + + /// Drop providers no client, request or timer holds any more. + fn evict(&mut self) { + self.entries + .retain(|e| !(e.idle() && Arc::strong_count(&e.provider) == 1)); + } + + /// The registered provider that `erased` (some type-erased handle to it) + /// points at. + pub fn by_address(&self, erased: *const ()) -> Option> { + self.entries + .iter() + .find(|e| Arc::as_ptr(&e.provider).cast::<()>() == erased) + .map(|e| Arc::clone(&e.provider)) + } + + fn index(&self, provider: &Arc

) -> usize { + self.entries + .iter() + .position(|e| Arc::ptr_eq(&e.provider, provider)) + .expect("providers come from Flights::insert") + } +} + +/// Scoped access to `provider`'s entry: the borrow cannot outlive `f`, so +/// nothing holds it across a call that may insert/evict or re-enter JS. +fn with_entry(provider: &Arc

, f: impl FnOnce(&mut Entry

) -> R) -> R { + let flights = P::flights(); + let i = flights.index(provider); + f(&mut flights.entries[i]) +} + +fn run_waiters(waiters: Vec>, result: &FlightResult) -> JsResult<()> { + // Every waiter runs even after one fails (a pending termination makes + // the rest's promise settlements no-ops): each owns something it frees. + let mut first_err = Ok(()); + for w in waiters { + let r = w(result.clone()); + if first_err.is_ok() { + first_err = r; + } + } + first_err +} + +/// Whether the registered waiters need the process to stay up for them. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Hold { + /// A promise or request is waiting. + Loop, + /// Background refresh or a synchronous probe: exit need not wait. + Nothing, +} + +/// Register `waiters`; start the chain unless it is already running for +/// this provider in this VM. +fn start( + global: &JSGlobalObject, + provider: &Arc

, + waiters: Vec>, + hold: Hold, +) -> JsResult<()> { + let joined = with_entry(provider, |e| { + if hold == Hold::Loop { + e.keep_alive.ref_(bun_io::js_vm_ctx()); + } + if let Some(existing) = &mut e.waiters { + existing.extend(waiters); + return true; + } + e.waiters = Some(waiters); + false + }); + if joined { + return Ok(()); + } + let io = Io::default(); + let chain = provider.begin(global, io.clone()); + let provider = Arc::clone(provider); + io::drive( + io.clone(), + chain, + Box::new(move |result| finish(&provider, &io, result)), + ) +} + +fn finish( + provider: &Arc

, + io: &Io, + result: Result, +) -> JsResult<()> { + let vm = VirtualMachine::get(); + let (waiters, held) = with_entry(provider, |e| { + let held = e.keep_alive.is_active(); + e.keep_alive.unref(bun_io::js_vm_ctx()); + (e.waiters.take().unwrap_or_default(), held) + }); + if io.interrupted() { + // Aborted by a VM stop phase, not the chain's verdict: cache nothing, + // and if anyone live is still waiting (test isolation), go again. + if !waiters.is_empty() && vm.script_allowed() { + let hold = if held { Hold::Loop } else { Hold::Nothing }; + return start(vm.global(), provider, waiters, hold); + } + return run_waiters(waiters, &Err(Arc::new(P::interrupted()))); + } + let cache = provider.cache(); + let (previous_expiration, used_since_last) = cache.take_usage(); + let settled = cache.settle(result); + // Keep the background refresh going while the value is in use; a + // provider nobody read since the last refresh goes quiet until its next + // use re-arms the timer (`keep_warm`). A refresh that brought nothing + // newer (same expiration, or a failure with the old value still good) is + // retried on a shortening schedule, but not into the last minute. + let in_use = used_since_last || !waiters.is_empty(); + let rearm = in_use + && settled.as_ref().is_ok_and(|v| { + v.expiration() > previous_expiration + || v.expiration() + .is_some_and(|e| e > now_secs() + MIN_REFRESH_INTERVAL) + }); + if rearm { + arm_refresh_timer(provider); + } else { + with_entry(provider, |e| e.refresh_timer = None); + } + run_waiters(waiters, &settled) +} + +fn arm_refresh_timer(provider: &Arc

) { + if !VirtualMachine::get().script_allowed() { + return; + } + let cache = provider.cache(); + let Some(delay_ms) = cache.peek().and_then(|v| cache.refresh_delay_ms(&v)) else { + return; + }; + let address = Arc::as_ptr(provider).cast::<()>() as usize; + with_entry(provider, |e| { + e.refresh_timer + .get_or_insert_with(|| CallbackTimer::new(on_refresh_timer::

, address)) + .schedule(delay_ms); + }); +} + +fn on_refresh_timer(address: usize) { + let vm = VirtualMachine::get(); + let Some(provider) = P::flights().by_address(address as *const ()) else { + return; + }; + with_entry(&provider, |e| e.refresh_timer = None); + if vm.script_allowed() { + refresh_ahead(vm.global(), &provider); + } +} + +/// Start a background refresh unless one is running or one just failed. +fn refresh_ahead(global: &JSGlobalObject, provider: &Arc

) { + if provider.cache().recent_error().is_none() { + let _ = start(global, provider, Vec::new(), Hold::Nothing); + } +} + +/// For a caller about to use a cached value: refresh it in the background if +/// it is close to expiry, else make sure the refresh timer is armed. +pub fn keep_warm(global: &JSGlobalObject, provider: &Arc

) { + if provider.cache().fresh().is_none() { + refresh_ahead(global, provider); + } else if with_entry(provider, |e| e.idle()) { + arm_refresh_timer(provider); + } +} + +/// Get a value with a comfortable lifetime left, without blocking: `then` +/// runs right away if a fresh one is cached (or a resolution failed moments +/// ago and a usable one is), otherwise once the (single, shared) resolution +/// or refresh completes. Always on `global`'s thread. +pub fn resolve_async( + global: &JSGlobalObject, + provider: &Arc

, + then: Continuation, +) -> JsResult<()> { + let cache = provider.cache(); + if let Some(v) = cache.fresh() { + keep_warm(global, provider); + return then(Ok(v)); + } + if let Some(e) = cache.recent_error() { + return then(cache.usable().ok_or(e)); + } + start(global, provider, vec![then], Hold::Loop) +} + +/// What a synchronous caller can have right now. +pub enum Now { + Ready(FlightResult), + /// The chain needs I/O and is running in the background; `previous` is + /// how the last attempt ended, if it failed. + Pending { + previous: Option>, + }, +} + +/// For synchronous callers: whatever can be had without waiting. A cached +/// value; else — since sources like environment variables and static +/// profile keys need no I/O — the result of a resolution that completes on +/// the spot. If the chain does need I/O it is left running in the background. +pub fn resolve_now_or_start( + global: &JSGlobalObject, + provider: &Arc

, +) -> Now { + let cache = provider.cache(); + if let Some(v) = cache.usable() { + keep_warm(global, provider); + return Now::Ready(Ok(v)); + } + if let Some(e) = cache.recent_error() { + return Now::Ready(Err(e)); + } + let previous = cache.last_error(); + if with_entry(provider, |e| e.waiters.is_some()) { + return Now::Pending { previous }; + } + let slot: std::rc::Rc>>> = Default::default(); + let writer = std::rc::Rc::clone(&slot); + let _ = start( + global, + provider, + vec![Box::new(move |result| { + writer.set(Some(result)); + Ok(()) + })], + Hold::Nothing, + ); + match slot.take() { + Some(result) => Now::Ready(result), + None => Now::Pending { previous }, + } +} + +/// A promise for `build(value)` once `provider` has a value (now, if one is +/// cached), rejected with `to_error(..)` if it cannot be had. Neither runs if +/// the VM is shutting down by then; an exception `build` leaves pending +/// becomes the rejection. +pub fn promise( + global: &JSGlobalObject, + provider: &Arc

, + to_error: fn(&JSGlobalObject, &ProviderError) -> JSValue, + build: impl FnOnce(&JSGlobalObject, &P::Value) -> JsResult + 'static, +) -> JsResult { + let promise = JSPromiseStrong::init(global); + let value = promise.value(); + let global_ref = GlobalRef::from(global); + resolve_async( + global, + provider, + Box::new(move |result| { + let global: &JSGlobalObject = &global_ref; + let mut promise = promise; + if !global.bun_vm().script_allowed() { + return Ok(()); + } + match result { + Ok(v) => { + let built = build(global, &v); + promise.settle(global, built) + } + Err(e) => promise.reject(global, Ok(to_error(global, &e))), + } + }), + )?; + Ok(value) +} diff --git a/src/runtime/webcore/cloud/gcp/chain.rs b/src/runtime/webcore/cloud/gcp/chain.rs new file mode 100644 index 000000000000..58b43fcb9fc1 --- /dev/null +++ b/src/runtime/webcore/cloud/gcp/chain.rs @@ -0,0 +1,794 @@ +//! Google Application Default Credentials → an OAuth2 access token (or an +//! OIDC identity token for a given audience): +//! +//! 1. `GOOGLE_APPLICATION_CREDENTIALS` — a service-account key file +//! (self-signed JWT exchanged at `oauth2.googleapis.com/token`) or an +//! `authorized_user` file (refresh token, what `gcloud auth +//! application-default login` writes) +//! 2. the well-known ADC file (`~/.config/gcloud/application_default_credentials.json`, +//! `%APPDATA%\\gcloud\\…` on Windows, or under `CLOUDSDK_CONFIG`) +//! 3. the metadata server (GCE, GKE, Cloud Run, Cloud Functions, App Engine …) +//! +//! Like the AWS chain: an unconfigured source is skipped, a configured but +//! failing one is an error; straight-line `async` code whose network I/O goes +//! through [`Io`], driven from the JS thread by `provider.rs`. + +use std::io::Write as _; + +use bstr::BStr; +use bun_core::strings; +use bun_jsc::JSGlobalObject; +use bun_s3_signing::ProviderError; +use bun_s3_signing::sigv4::uri_encode_into; +use bun_sys::{Fd, File}; + +use super::jwt; +use crate::webcore::cloud::cache::{Expiring, now_secs}; +use crate::webcore::cloud::env::Env; +use crate::webcore::cloud::form_encode; +use crate::webcore::cloud::io::{ChainFuture, HttpError, HttpRequest, HttpResponse, Io}; +use crate::webcore::cloud::json; + +const TOKEN_ENDPOINT_TIMEOUT_MS: u32 = 30_000; +/// Sanity bound on `expires_in` from a token endpoint. +const MAX_TOKEN_LIFETIME_SECS: f64 = 7.0 * 24.0 * 3600.0; + +pub const DEFAULT_SCOPE: &[u8] = b"https://www.googleapis.com/auth/cloud-platform"; +const DEFAULT_TOKEN_URI: &[u8] = b"https://oauth2.googleapis.com/token"; +const METADATA_HOST: &[u8] = b"metadata.google.internal"; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Source { + ServiceAccount, + AuthorizedUser, + Metadata, +} + +impl Source { + pub const fn as_str(self) -> &'static str { + match self { + Source::ServiceAccount => "service-account", + Source::AuthorizedUser => "authorized-user", + Source::Metadata => "metadata", + } + } +} + +/// Which credentials a `GCPClient` uses. +#[derive(Clone, PartialEq, Eq)] +pub enum CredentialSource { + /// Application Default Credentials (env, gcloud file, metadata server). + Default, + /// A key file path (`keyFile`), as if `GOOGLE_APPLICATION_CREDENTIALS` named it. + File(std::sync::Arc<[u8]>), + /// The key file's JSON, given inline (`credentials`). + Inline(std::sync::Arc<[u8]>), +} + +/// What kind of token to mint. +#[derive(Clone, PartialEq, Eq, Hash)] +pub enum TokenRequest { + /// OAuth2 access token for these scopes (space-joined). + Access { scopes: Box<[u8]> }, + /// OIDC ID token for this audience. + Identity { audience: Box<[u8]> }, +} + +pub struct Token { + pub token: Box<[u8]>, + /// Unix epoch seconds. + pub expiration: u64, + pub source: Source, + /// Service account email, when known. + pub email: Option>, + pub project_id: Option>, + /// `quota_project_id` from an authorized_user file / `GOOGLE_CLOUD_QUOTA_PROJECT`. + pub quota_project_id: Option>, +} + +impl Expiring for Token { + fn expiration(&self) -> Option { + Some(self.expiration) + } +} + +impl Drop for Token { + fn drop(&mut self) { + bun_core::secure_zero_slice(&mut self.token); + } +} + +/// Environment snapshot, captured on the JS thread. +#[derive(Default)] +pub struct GcpConfig { + /// Inline key JSON from `new GCPClient({ credentials })`; wins over files. + pub credentials_json: Option>, + pub credentials_file: Option>, + pub cloudsdk_config: Option>, + pub home: Option>, + pub appdata: Option>, + pub metadata_host: Option>, + pub metadata_disabled: bool, + pub metadata_timeout_ms: u32, + pub quota_project: Option>, + pub universe_domain: Option>, + pub https_proxy: Option>, + pub no_proxy: Option>, +} + +fn owned(v: Option>) -> Option> { + v.filter(|s| !s.is_empty()).map(Vec::into_boxed_slice) +} + +impl GcpConfig { + pub fn capture(global: &JSGlobalObject, source: &CredentialSource) -> GcpConfig { + let env = Env::new(global); + GcpConfig { + credentials_json: match source { + CredentialSource::Inline(json) => Some(std::sync::Arc::clone(json)), + _ => None, + }, + credentials_file: match source { + CredentialSource::File(path) => Some(Box::from(&**path)), + _ => owned(env.get(b"GOOGLE_APPLICATION_CREDENTIALS")), + }, + cloudsdk_config: owned(env.get(b"CLOUDSDK_CONFIG")), + home: owned( + env.get(b"HOME") + .or_else(|| env.get(b"USERPROFILE")) + .or_else(|| bun_core::env_var::HOME.get().map(<[u8]>::to_vec)), + ), + appdata: owned(env.get(b"APPDATA")), + metadata_host: owned( + env.get(b"GCE_METADATA_HOST") + .or_else(|| env.get(b"GCE_METADATA_IP")), + ), + metadata_disabled: env + .get(b"NO_GCE_CHECK") + .is_some_and(|v| v.eq_ignore_ascii_case(b"true") || v == b"1"), + metadata_timeout_ms: env + .get(b"GCE_METADATA_TIMEOUT") + .and_then(|s| { + core::str::from_utf8(&s) + .ok() + .and_then(|s| s.trim().parse::().ok()) + }) + .filter(|v| v.is_finite() && *v > 0.0) + // google-auth's default is 3s per attempt on the first probe. + .map_or(3000, |secs| (secs * 1000.0).clamp(50.0, 120_000.0) as u32), + quota_project: owned(env.get(b"GOOGLE_CLOUD_QUOTA_PROJECT")), + universe_domain: owned(env.get(b"GOOGLE_CLOUD_UNIVERSE_DOMAIN")), + https_proxy: owned(env.get_proxy_var(b"https_proxy", b"HTTPS_PROXY")), + no_proxy: owned(env.get_proxy_var(b"no_proxy", b"NO_PROXY")), + } + } + + fn well_known_file(&self) -> Option> { + const NAME: &[u8] = b"application_default_credentials.json"; + let mut p = Vec::new(); + if let Some(dir) = &self.cloudsdk_config { + p.extend_from_slice(strings::trim_right(dir, b"/\\")); + } else if cfg!(windows) { + let appdata = self.appdata.as_deref()?; + p.extend_from_slice(strings::trim_right(appdata, b"/\\")); + p.push(bun_paths::SEP); + p.extend_from_slice(b"gcloud"); + } else { + let home = self.home.as_deref()?; + p.extend_from_slice(strings::trim_right(home, b"/")); + p.extend_from_slice(b"/.config/gcloud"); + } + p.push(bun_paths::SEP); + p.extend_from_slice(NAME); + Some(p) + } + + fn proxy_for(&self, url: &[u8]) -> Option<&[u8]> { + let parsed = bun_url::URL::parse(url); + if !parsed.is_https() { + return None; + } + let proxy = self.https_proxy.as_deref()?; + if let Some(no_proxy) = self.no_proxy.as_deref() { + if bun_http::no_proxy_matches(no_proxy, parsed.hostname, parsed.host) { + return None; + } + } + Some(proxy) + } +} + +struct Resolver { + cfg: GcpConfig, + request: TokenRequest, + io: Io, + notes: Vec, +} + +pub fn resolve( + cfg: GcpConfig, + request: TokenRequest, + io: Io, +) -> ChainFuture> { + Box::pin(async move { + let mut r = Resolver { + cfg, + request, + io, + notes: Vec::new(), + }; + let t = r.run().await?; + if t.expiration <= now_secs() + bun_s3_signing::AwsCredentials::EXPIRY_MARGIN_SECONDS { + return Err(err(format_args!( + "the {} token was already expired when it arrived; check this machine's clock", + t.source.as_str() + ))); + } + Ok(t) + }) +} + +fn err(args: core::fmt::Arguments<'_>) -> ProviderError { + let mut v = Vec::new(); + let _ = v.write_fmt(args); + ProviderError::new("ERR_GCP_CREDENTIALS", v) +} + +macro_rules! fail { + ($($arg:tt)*) => { err(format_args!($($arg)*)) }; +} + +fn snippet(body: &[u8]) -> &BStr { + let body = body.trim_ascii(); + BStr::new(&body[..body.len().min(300)]) +} + +/// `{"error": "...", "error_description": "..."}` from Google's token endpoint. +fn oauth_error(body: &[u8]) -> String { + json::parse(body, |o| { + let e = o.str(b"error"); + let d = o.str(b"error_description"); + match (e, d) { + (Some(e), Some(d)) => format!("{}: {}", BStr::new(&e), BStr::new(&d)), + (Some(e), None) => format!("{}", BStr::new(&e)), + _ => format!("{}", snippet(body)), + } + }) + .unwrap_or_else(|| format!("{}", snippet(body))) +} + +impl Resolver { + fn note(&mut self, args: core::fmt::Arguments<'_>) { + if !self.notes.is_empty() { + self.notes.extend_from_slice(b"; "); + } + let _ = self.notes.write_fmt(args); + } + + async fn http(&self, mut req: HttpRequest, proxied: bool) -> Result { + if proxied { + req.proxy_url = self.cfg.proxy_for(&req.url).map(Box::from); + } + self.io.http(req).await + } + + async fn run(&mut self) -> Result { + // 0. explicit key material from `new GCPClient({ credentials })` + if let Some(json) = self.cfg.credentials_json.clone() { + let mut t = self.from_credentials_file(b"", &json).await?; + if t.quota_project_id.is_none() { + t.quota_project_id.clone_from(&self.cfg.quota_project); + } + return Ok(t); + } + + // 1. GOOGLE_APPLICATION_CREDENTIALS / `new GCPClient({ keyFile })` + if let Some(path) = self.cfg.credentials_file.clone() { + let bytes = File::read_from(Fd::cwd(), &path).map_err(|e| { + fail!( + "could not read credentials file {}: {}", + BStr::new(&path), + BStr::new(e.name()) + ) + })?; + let mut t = self.from_credentials_file(&path, &bytes).await?; + if t.quota_project_id.is_none() { + t.quota_project_id.clone_from(&self.cfg.quota_project); + } + return Ok(t); + } + self.note(format_args!("GOOGLE_APPLICATION_CREDENTIALS (not set)")); + + // 2. well-known ADC file + match self.cfg.well_known_file() { + Some(path) => match File::read_from(Fd::cwd(), &path) { + Ok(bytes) => { + let mut t = self.from_credentials_file(&path, &bytes).await?; + if t.quota_project_id.is_none() { + t.quota_project_id.clone_from(&self.cfg.quota_project); + } + return Ok(t); + } + Err(e) if crate::webcore::cloud::not_found(&e) || !bun_sys::exists(&path) => { + // (…or a parent directory that cannot be searched: like + // google-auth-library's existsSync probe, that is "absent".) + self.note(format_args!( + "application default credentials ({} not found; `gcloud auth application-default login` creates it)", + BStr::new(&path) + )) + } + Err(e) => { + return Err(fail!( + "application default credentials: could not read {} ({})", + BStr::new(&path), + BStr::new(e.name()) + )); + } + }, + None => self.note(format_args!( + "application default credentials ({} is not set)", + if cfg!(windows) { "APPDATA" } else { "HOME" } + )), + } + + // 3. metadata server + match self.from_metadata().await? { + Some(t) => Ok(t), + None => Err(ProviderError::new( + "ERR_GCP_MISSING_CREDENTIALS", + format!( + "Could not find Google Cloud credentials in any source: {}", + BStr::new(&self.notes) + ) + .into_bytes(), + )), + } + } + + async fn from_credentials_file( + &self, + path: &[u8], + bytes: &[u8], + ) -> Result { + let cfg = &self.cfg; + struct F { + kind: Option>, + client_email: Option>, + private_key: Option>, + private_key_id: Option>, + token_uri: Option>, + project_id: Option>, + client_id: Option>, + client_secret: Option>, + refresh_token: Option>, + quota_project_id: Option>, + universe_domain: Option>, + } + let Some(f) = json::parse(bytes, |o| F { + kind: o.str(b"type"), + client_email: o.str(b"client_email"), + private_key: o.str(b"private_key"), + private_key_id: o.str(b"private_key_id"), + token_uri: o.str(b"token_uri"), + project_id: o.str(b"project_id"), + client_id: o.str(b"client_id"), + client_secret: o.str(b"client_secret"), + refresh_token: o.str(b"refresh_token"), + quota_project_id: o.str(b"quota_project_id"), + universe_domain: o.str(b"universe_domain"), + }) else { + return Err(fail!( + "credentials file {} is not a JSON object", + BStr::new(path) + )); + }; + match f.kind.as_deref() { + Some(b"service_account") => { + let (Some(email), Some(key)) = (&f.client_email, &f.private_key) else { + return Err(fail!( + "service account file {} is missing client_email or private_key", + BStr::new(path) + )); + }; + let token_uri: Vec = + match (&f.token_uri, &f.universe_domain, &cfg.universe_domain) { + (Some(u), _, _) => u.to_vec(), + (None, Some(d), _) | (None, None, Some(d)) if &**d != b"googleapis.com" => { + format!("https://oauth2.{}/token", BStr::new(d)).into_bytes() + } + _ => DEFAULT_TOKEN_URI.to_vec(), + }; + let mut t = self + .service_account_token(email, key, f.private_key_id.as_deref(), &token_uri) + .await?; + t.project_id = f.project_id; + t.quota_project_id = f.quota_project_id; + Ok(t) + } + Some(b"authorized_user") => { + let (Some(id), Some(secret), Some(rt)) = + (&f.client_id, &f.client_secret, &f.refresh_token) + else { + return Err(fail!( + "authorized_user file {} is missing client_id, client_secret or refresh_token", + BStr::new(path) + )); + }; + let token_uri = f.token_uri.as_deref().unwrap_or(DEFAULT_TOKEN_URI); + let mut t = self + .authorized_user_token(path, id, secret, rt, token_uri) + .await?; + t.quota_project_id = f.quota_project_id; + Ok(t) + } + Some(other) => Err(fail!( + "credentials file {} has type \"{}\"; only \"service_account\" and \"authorized_user\" are supported (external_account / impersonation are not yet)", + BStr::new(path), + BStr::new(other) + )), + None => Err(fail!( + "credentials file {} has no \"type\" field", + BStr::new(path) + )), + } + } + + async fn post_token_endpoint( + &self, + what: &str, + token_uri: &[u8], + body: Vec, + ) -> Result { + let parsed = bun_url::URL::parse(token_uri); + if !(parsed.is_https() + || (parsed.is_http() + && (parsed.hostname == b"localhost" || parsed.hostname == b"127.0.0.1"))) + { + return Err(fail!( + "{what}: token_uri \"{}\" must be https://", + BStr::new(token_uri) + )); + } + let req = HttpRequest::post(token_uri.to_vec(), body) + .header(b"content-type", b"application/x-www-form-urlencoded") + .header(b"accept", b"application/json") + .timeout(TOKEN_ENDPOINT_TIMEOUT_MS); + let res = self + .http(req, true) + .await + .map_err(|e| fail!("{what}: request to {} failed: {e}", BStr::new(token_uri)))?; + if res.status != 200 { + return Err(fail!( + "{what}: {} answered HTTP {}: {}", + BStr::new(token_uri), + res.status, + oauth_error(&res.body) + )); + } + Ok(res) + } + + async fn service_account_token( + &self, + email: &[u8], + private_key: &[u8], + key_id: Option<&[u8]>, + token_uri: &[u8], + ) -> Result { + let request = &self.request; + let now = now_secs(); + let (scope, target_audience) = match request { + TokenRequest::Access { scopes } => (Some(&**scopes), None), + TokenRequest::Identity { audience } => (None, Some(&**audience)), + }; + let unsigned = jwt::unsigned( + key_id, + &jwt::Claims { + iss: email, + scope, + target_audience, + aud: token_uri, + iat: now.saturating_sub(10), + exp: now.saturating_sub(10) + 3600, + }, + ); + // The RSA signature is a millisecond or two of CPU: not on the JS thread. + let key = private_key.to_vec(); + let assertion = match self + .io + .blocking(move || { + let signed = jwt::sign_rs256(&key, unsigned); + let mut key = key; + bun_core::secure_zero_slice(&mut key); + signed + }) + .await + { + Some(signed) => { + signed.map_err(|e| fail!("service account {}: {e}", BStr::new(email)))? + } + None => return Err(fail!("the JavaScript VM is shutting down")), + }; + let mut body = Vec::with_capacity(assertion.len() + 80); + form_encode( + &mut body, + &[ + ( + b"grant_type", + b"urn:ietf:params:oauth:grant-type:jwt-bearer", + ), + (b"assertion", &assertion), + ], + ); + let what = format!("service account {}", BStr::new(email)); + let res = self.post_token_endpoint(&what, token_uri, body).await?; + let mut t = Self::token_from_response(&what, &res.body, request, Source::ServiceAccount)?; + t.email = Some(Box::from(email)); + Ok(t) + } + + async fn authorized_user_token( + &self, + path: &[u8], + client_id: &[u8], + client_secret: &[u8], + refresh_token: &[u8], + token_uri: &[u8], + ) -> Result { + let request = &self.request; + let mut body = Vec::with_capacity(256 + refresh_token.len()); + let mut pairs: Vec<(&[u8], &[u8])> = vec![ + (b"grant_type", b"refresh_token"), + (b"client_id", client_id), + (b"client_secret", client_secret), + (b"refresh_token", refresh_token), + ]; + match request { + TokenRequest::Access { scopes } if &**scopes != DEFAULT_SCOPE => { + pairs.push((b"scope", scopes)); + } + TokenRequest::Access { .. } => {} + // As google-auth-library's UserRefreshClient.fetchIdToken does; + // Google may still answer with a token for gcloud's own client + // ID, which Cloud Run / Cloud Functions accept and IAP does not. + TokenRequest::Identity { audience } => pairs.push((b"target_audience", audience)), + } + form_encode(&mut body, &pairs); + let what = format!("authorized user credentials ({})", BStr::new(path)); + let res = self.post_token_endpoint(&what, token_uri, body).await?; + Self::token_from_response(&what, &res.body, request, Source::AuthorizedUser) + } + + /// `{access_token, expires_in, id_token?, token_type}` + fn token_from_response( + what: &str, + body: &[u8], + request: &TokenRequest, + source: Source, + ) -> Result { + let parsed = json::parse(body, |o| { + ( + o.str(b"access_token"), + o.str(b"id_token"), + o.number(b"expires_in"), + ) + }); + let Some((access, id, expires_in)) = parsed else { + return Err(fail!( + "{what}: token endpoint returned an unexpected response: {}", + snippet(body) + )); + }; + let now = now_secs(); + match request { + TokenRequest::Access { .. } => { + let Some(token) = access else { + return Err(fail!("{what}: token endpoint response has no access_token")); + }; + let expires_in = expires_in + .filter(|e| e.is_finite() && *e > 0.0) + .unwrap_or(3600.0) + .min(MAX_TOKEN_LIFETIME_SECS) as u64; + Ok(Token { + token, + expiration: now + expires_in, + source, + email: None, + project_id: None, + quota_project_id: None, + }) + } + TokenRequest::Identity { .. } => { + let Some(token) = id else { + return Err(fail!("{what}: token endpoint response has no id_token")); + }; + let expiration = jwt::unverified_exp(&token).unwrap_or(now + 3600); + Ok(Token { + token, + expiration, + source, + email: None, + project_id: None, + quota_project_id: None, + }) + } + } + } + + async fn from_metadata(&mut self) -> Result, ProviderError> { + let request = self.request.clone(); + let custom_host = self.cfg.metadata_host.is_some(); + if self.cfg.metadata_disabled { + self.note(format_args!("metadata server (NO_GCE_CHECK is set)")); + return Ok(None); + } + let base: Vec = match &self.cfg.metadata_host { + Some(h) if h.starts_with(b"http://") || h.starts_with(b"https://") => { + strings::trim_right(h, b"/").to_vec() + } + Some(h) => { + let mut v = b"http://".to_vec(); + v.extend_from_slice(strings::trim_right(h, b"/")); + v + } + None => { + let mut v = b"http://".to_vec(); + v.extend_from_slice(METADATA_HOST); + v + } + }; + let mut url = base.clone(); + url.extend_from_slice(b"/computeMetadata/v1/instance/service-accounts/default/"); + match &request { + TokenRequest::Access { scopes } => { + url.extend_from_slice(b"token"); + if &**scopes != DEFAULT_SCOPE { + url.extend_from_slice(b"?scopes="); + // The metadata server wants them comma-separated. + let joined: Vec = scopes + .iter() + .map(|c| if *c == b' ' { b',' } else { *c }) + .collect(); + uri_encode_into(&mut url, &joined, false); + } + } + TokenRequest::Identity { audience } => { + url.extend_from_slice(b"identity?format=full&audience="); + uri_encode_into(&mut url, audience, false); + } + } + let mut last_transport_err = None; + let mut res = None; + for attempt in 0..3 { + match self.metadata_get(url.clone()).await { + Ok(r) if r.status >= 500 && attempt < 2 => { + res = Some(r); + } + Ok(r) => { + res = Some(r); + break; + } + Err(e) => { + // Off-GCP the hostname does not resolve / the address does + // not route: that is "not on GCP", not an error. Only a + // host someone configured is worth retrying. + let retry = custom_host && !e.is_interruption(); + last_transport_err = Some(e); + if !retry { + break; + } + } + } + } + let Some(res) = res else { + let e = last_transport_err + .map(|e| e.to_string()) + .unwrap_or_default(); + self.note(format_args!( + "metadata server ({} is unreachable: {e})", + BStr::new(&base) + )); + return Ok(None); + }; + if res + .header(b"metadata-flavor") + .is_none_or(|v| !v.eq_ignore_ascii_case(b"Google")) + && (res.status != 200 || !custom_host) + { + self.note(format_args!( + "metadata server ({} did not answer like one, HTTP {})", + BStr::new(&base), + res.status + )); + return Ok(None); + } + if res.status == 404 { + return Err(fail!( + "metadata server has no default service account attached (HTTP 404): {}", + snippet(&res.body) + )); + } + if res.status != 200 { + return Err(fail!( + "metadata server {} answered HTTP {}: {}", + BStr::new(&url), + res.status, + snippet(&res.body) + )); + } + let now = now_secs(); + let mut token = match &request { + TokenRequest::Access { .. } => { + let parsed = json::parse(&res.body, |o| { + (o.str(b"access_token"), o.number(b"expires_in")) + }); + let Some((Some(token), expires_in)) = parsed else { + return Err(fail!( + "metadata server returned an unexpected token response: {}", + snippet(&res.body) + )); + }; + Token { + token, + expiration: now + + expires_in + .filter(|e| e.is_finite() && *e > 0.0) + .unwrap_or(3600.0) + .min(MAX_TOKEN_LIFETIME_SECS) as u64, + source: Source::Metadata, + email: None, + project_id: None, + quota_project_id: None, + } + } + TokenRequest::Identity { .. } => { + let body = res.body.trim_ascii(); + if body.is_empty() || strings::count_char(body, b'.') != 2 { + return Err(fail!( + "metadata server returned an unexpected identity response: {}", + snippet(&res.body) + )); + } + Token { + token: Box::from(body), + expiration: jwt::unverified_exp(body).unwrap_or(now + 3600), + source: Source::Metadata, + email: None, + project_id: None, + quota_project_id: None, + } + } + }; + // Best-effort extras; cheap and cached alongside the token. + token.email = self + .metadata_text( + &base, + "/computeMetadata/v1/instance/service-accounts/default/email", + ) + .await; + token.project_id = self + .metadata_text(&base, "/computeMetadata/v1/project/project-id") + .await; + token.quota_project_id.clone_from(&self.cfg.quota_project); + Ok(Some(token)) + } + + async fn metadata_get(&self, url: Vec) -> Result { + let req = HttpRequest::get(url) + .header(b"metadata-flavor", b"Google") + .timeout(self.cfg.metadata_timeout_ms); + self.http(req, false).await + } + + async fn metadata_text(&self, base: &[u8], path: &str) -> Option> { + let mut u = base.to_vec(); + u.extend_from_slice(path.as_bytes()); + self.metadata_get(u) + .await + .ok() + .filter(|r| r.status == 200) + .map(|r| r.body.trim_ascii().to_vec()) + .filter(|b| !b.is_empty()) + .map(Vec::into_boxed_slice) + } +} diff --git a/src/runtime/webcore/cloud/gcp/js.rs b/src/runtime/webcore/cloud/gcp/js.rs new file mode 100644 index 000000000000..17abdf7f62a9 --- /dev/null +++ b/src/runtime/webcore/cloud/gcp/js.rs @@ -0,0 +1,363 @@ +//! `Bun.GCPClient` (and `Bun.gcp`, an instance with default options): +//! `fetch()`, `accessToken()`, `idToken()`. + +use std::sync::Arc; + +use bun_jsc::bun_string_jsc::create_utf8_for_js; +use bun_jsc::{CallFrame, JSGlobalObject, JSValue, JsResult, StringJsc as _}; + +use super::chain::{CredentialSource, DEFAULT_SCOPE, Token, TokenRequest}; +use super::provider::{TokenProvider, provider_for}; +use crate::webcore::cloud::aws::fetch_signing::provider_error_to_js; +use crate::webcore::cloud::flight; +use crate::webcore::fetch::{FetchAuth, fetch_with_auth}; +use crate::webcore::s3::credentials_jsc::get_truthy_string_utf8; + +/// A `GCPClient`'s configuration: where credentials come from and the +/// default token to mint. +pub struct ClientOptions { + pub source: CredentialSource, + /// Space-joined default scopes for access tokens. + pub scopes: Box<[u8]>, + /// When set, `fetch()` sends an ID token for this audience by default. + pub audience: Option>, + /// The provider for requests that override neither. + pub default_provider: Arc, +} + +impl ClientOptions { + fn from_js(global: &JSGlobalObject, value: JSValue) -> JsResult { + struct Parsed { + source: CredentialSource, + scopes: Box<[u8]>, + audience: Option>, + } + impl Parsed { + fn finish(self) -> ClientOptions { + let request = match &self.audience { + Some(a) => TokenRequest::Identity { + audience: a.clone(), + }, + None => TokenRequest::Access { + scopes: self.scopes.clone(), + }, + }; + ClientOptions { + default_provider: provider_for(request, &self.source), + source: self.source, + scopes: self.scopes, + audience: self.audience, + } + } + } + let mut out = Parsed { + source: CredentialSource::Default, + scopes: Box::from(DEFAULT_SCOPE), + audience: None, + }; + if !value.is_object() { + return Ok(out.finish()); + } + if let Some(path) = get_truthy_string_utf8(value, global, b"keyFile", true)? { + out.source = CredentialSource::File(Arc::from(path.slice())); + } + if let Some(v) = value.get_truthy(global, "credentials")? { + let json: Vec = if v.is_string() { + let s = bun_core::OwnedString::new(bun_core::String::from_js(v, global)?); + s.to_utf8().slice().to_vec() + } else if v.is_object() { + let mut s = bun_core::String::empty(); + v.json_stringify_fast(global, &mut s)?; + let s = bun_core::OwnedString::new(s); + s.to_utf8().slice().to_vec() + } else { + return Err(global.throw_invalid_arguments(format_args!( + "credentials must be a service-account / authorized_user key object or its JSON string" + ))); + }; + out.source = CredentialSource::Inline(Arc::from(json.into_boxed_slice())); + } + if let Some(a) = audience_from_js(global, value)? { + out.audience = Some(a); + } + if let Some(scopes) = value.get_truthy(global, "scopes")? { + out.scopes = scopes_from_js(global, scopes)?; + } + Ok(out.finish()) + } +} + +#[bun_jsc::JsClass] +pub struct GCPClient { + pub(crate) options: Arc, +} + +impl GCPClient { + pub(crate) fn constructor(global: &JSGlobalObject, frame: &CallFrame) -> JsResult> { + let arg = frame + .arguments() + .first() + .copied() + .unwrap_or(JSValue::UNDEFINED); + if !arg.is_undefined_or_null() && !arg.is_object() { + return Err(global.throw_invalid_arguments(format_args!( + "GCPClient options must be an object like {{ keyFile, credentials, scopes, audience }}" + ))); + } + Ok(Box::new(GCPClient { + options: Arc::new(ClientOptions::from_js(global, arg)?), + })) + } + + /// `Bun.gcp`. + pub fn default(global: &JSGlobalObject) -> JsResult> { + Ok(Box::new(GCPClient { + options: Arc::new(ClientOptions::from_js(global, JSValue::UNDEFINED)?), + })) + } + + /// `client.fetch(input, init?)` — `fetch()` with a bearer token attached. + #[bun_jsc::host_fn(method)] + pub(crate) fn fetch( + this: &Self, + global: &JSGlobalObject, + frame: &CallFrame, + ) -> JsResult { + fetch_with_auth(global, frame, FetchAuth::Gcp(Arc::clone(&this.options))) + } + + /// `client.accessToken({ scopes?, refresh? })` + #[bun_jsc::host_fn(method)] + pub(crate) fn access_token( + this: &Self, + global: &JSGlobalObject, + frame: &CallFrame, + ) -> JsResult { + let opts = frame.arguments().first().copied(); + if let Some(v) = opts { + if !v.is_undefined_or_null() && !v.is_object() { + return Err(global.throw_invalid_arguments(format_args!( + "accessToken() expects an options object like {{ scopes?: string | string[] }}" + ))); + } + } + let scopes = match opts.filter(|o| o.is_object()) { + Some(o) => match o.get_truthy(global, "scopes")? { + Some(scopes) => scopes_from_js(global, scopes)?, + None => this.options.scopes.clone(), + }, + None => this.options.scopes.clone(), + }; + let refresh = refresh_from(global, opts)?; + this.start(global, TokenRequest::Access { scopes }, refresh) + } + + /// `client.idToken(audience | { audience, refresh? })` + #[bun_jsc::host_fn(method)] + pub(crate) fn id_token( + this: &Self, + global: &JSGlobalObject, + frame: &CallFrame, + ) -> JsResult { + let arg = frame + .arguments() + .first() + .copied() + .unwrap_or(JSValue::UNDEFINED); + let audience: Box<[u8]> = if arg.is_string() { + let s = bun_core::OwnedString::new(bun_core::String::from_js(arg, global)?); + checked_audience(global, s.to_utf8().slice())? + } else if arg.is_object() + && let Some(a) = audience_from_js(global, arg)? + { + a + } else { + this.options.audience.clone().unwrap_or_default() + }; + if audience.is_empty() { + return Err(global.throw_invalid_arguments(format_args!( + "idToken() needs an audience: idToken(\"https://my-service.run.app\") or idToken({{ audience }})" + ))); + } + let refresh = refresh_from(global, Some(arg))?; + this.start(global, TokenRequest::Identity { audience }, refresh) + } + + fn start( + &self, + global: &JSGlobalObject, + request: TokenRequest, + refresh: bool, + ) -> JsResult { + let provider = provider_for(request, &self.options.source); + if refresh { + provider.mark_stale(); + } + flight::promise(global, &provider, provider_error_to_js, |global, t| { + token_to_js(global, t) + }) + } +} + +pub fn token_to_js(global: &JSGlobalObject, t: &Token) -> JsResult { + let obj = JSValue::create_empty_object(global, 6); + obj.put( + global, + b"token".as_slice(), + create_utf8_for_js(global, &t.token)?, + ); + obj.put( + global, + b"expiration".as_slice(), + JSValue::from_date_number(global, t.expiration as f64 * 1000.0), + ); + obj.put( + global, + b"source".as_slice(), + create_utf8_for_js(global, t.source.as_str().as_bytes())?, + ); + if let Some(e) = &t.email { + obj.put(global, b"email".as_slice(), create_utf8_for_js(global, e)?); + } + if let Some(p) = &t.project_id { + obj.put( + global, + b"projectId".as_slice(), + create_utf8_for_js(global, p)?, + ); + } + if let Some(q) = &t.quota_project_id { + obj.put( + global, + b"quotaProjectId".as_slice(), + create_utf8_for_js(global, q)?, + ); + } + Ok(obj) +} + +fn is_valid_scope(s: &[u8]) -> bool { + !s.is_empty() && s.iter().all(|c| c.is_ascii_graphic() && *c != b',') +} + +/// `options.audience`, checked. +fn audience_from_js(global: &JSGlobalObject, options: JSValue) -> JsResult>> { + match get_truthy_string_utf8(options, global, b"audience", true)? { + Some(a) => checked_audience(global, a.slice()).map(Some), + None => Ok(None), + } +} + +fn checked_audience(global: &JSGlobalObject, audience: &[u8]) -> JsResult> { + if crate::webcore::s3::credentials_jsc::contains_newline_or_cr(audience) { + return Err( + global.throw_invalid_arguments(format_args!("audience must not contain newlines")) + ); + } + Ok(Box::from(audience)) +} + +/// A `scopes: string | string[]` value → space-joined scope URLs. +pub fn scopes_from_js(global: &JSGlobalObject, v: JSValue) -> JsResult> { + let bad = || { + global.throw_invalid_arguments(format_args!( + "scopes must be a scope URL string or an array of them" + )) + }; + let mut joined: Vec = Vec::new(); + let push = |global: &JSGlobalObject, item: JSValue, joined: &mut Vec| -> JsResult { + if !item.is_string() { + return Ok(false); + } + let s = bun_core::OwnedString::new(bun_core::String::from_js(item, global)?); + let utf8 = s.to_utf8(); + for scope in bun_core::strings::split_any(utf8.slice(), b" ,") { + if scope.is_empty() { + continue; + } + if !is_valid_scope(scope) { + return Ok(false); + } + if !joined.is_empty() { + joined.push(b' '); + } + // Bare names like "cloud-platform" expand to the googleapis.com URL. + if !scope.starts_with(b"https://") + && !scope.starts_with(b"openid") + && scope != b"email" + && scope != b"profile" + { + joined.extend_from_slice(b"https://www.googleapis.com/auth/"); + } + joined.extend_from_slice(scope); + } + Ok(true) + }; + if v.is_string() { + if !push(global, v, &mut joined)? { + return Err(bad()); + } + } else if v.is_array() { + let mut iter = v.array_iterator(global)?; + while let Some(item) = iter.next()? { + if !push(global, item, &mut joined)? { + return Err(bad()); + } + } + } else { + return Err(bad()); + } + if joined.is_empty() { + return Ok(Box::from(DEFAULT_SCOPE)); + } + Ok(joined.into_boxed_slice()) +} + +/// The per-request view: which token this `fetch()` should carry. +pub struct GcpFetchOptions { + pub provider: Arc, +} + +impl GcpFetchOptions { + /// `inits`: the call's init dicts; the last one naming an `audience` or + /// `scopes` decides the token, otherwise the client's default. + pub fn from_js_with_base( + global: &JSGlobalObject, + inits: &[JSValue], + base: &ClientOptions, + ) -> JsResult { + let mut request = None; + for value in inits.iter().copied().filter(|v| v.is_object()) { + let scopes = value.get_truthy(global, "scopes")?; + if let Some(audience) = audience_from_js(global, value)? { + if scopes.is_some() { + return Err(global.throw_invalid_arguments(format_args!( + "audience (an ID token) and scopes (an access token) are mutually exclusive" + ))); + } + request = Some(TokenRequest::Identity { audience }); + } else if let Some(scopes) = scopes { + request = Some(TokenRequest::Access { + scopes: scopes_from_js(global, scopes)?, + }); + } + } + Ok(Self { + provider: match request { + Some(request) => provider_for(request, &base.source), + None => Arc::clone(&base.default_provider), + }, + }) + } + + pub fn needs_resolution(&self) -> bool { + self.provider.cached_usable().is_none() + } +} + +fn refresh_from(global: &JSGlobalObject, opts: Option) -> JsResult { + match opts.filter(|o| o.is_object()) { + Some(o) => Ok(o.get_boolean_strict(global, "refresh")?.unwrap_or(false)), + None => Ok(false), + } +} diff --git a/src/runtime/webcore/cloud/gcp/jwt.rs b/src/runtime/webcore/cloud/gcp/jwt.rs new file mode 100644 index 000000000000..d6fa2d946b7c --- /dev/null +++ b/src/runtime/webcore/cloud/gcp/jwt.rs @@ -0,0 +1,88 @@ +//! Just enough JWT to talk to Google's OAuth token endpoint: build and +//! RS256-sign a service-account assertion, and read `exp` back out of an ID +//! token. + +use std::io::Write as _; + +use crate::webcore::cloud::json; + +fn b64url(out: &mut Vec, bytes: &[u8]) { + out.extend_from_slice(&bun_base64::simdutf_encode_url_safe_alloc(bytes)); +} + +pub struct Claims<'a> { + pub iss: &'a [u8], + /// Space-separated OAuth scopes (access tokens) … + pub scope: Option<&'a [u8]>, + /// … or the audience an ID token is minted for. + pub target_audience: Option<&'a [u8]>, + pub aud: &'a [u8], + pub iat: u64, + pub exp: u64, +} + +/// `header.claims` of a JWT, ready for [`sign_rs256`]. +pub fn unsigned(key_id: Option<&[u8]>, claims: &Claims<'_>) -> Vec { + let mut header = Vec::with_capacity(96); + header.extend_from_slice(b"{\"alg\":\"RS256\",\"typ\":\"JWT\""); + if let Some(kid) = key_id { + header.extend_from_slice(b",\"kid\":"); + json::push_string(&mut header, kid); + } + header.push(b'}'); + + let mut payload = Vec::with_capacity(256); + payload.extend_from_slice(b"{\"iss\":"); + json::push_string(&mut payload, claims.iss); + payload.extend_from_slice(b",\"sub\":"); + json::push_string(&mut payload, claims.iss); + payload.extend_from_slice(b",\"aud\":"); + json::push_string(&mut payload, claims.aud); + if let Some(scope) = claims.scope { + payload.extend_from_slice(b",\"scope\":"); + json::push_string(&mut payload, scope); + } + if let Some(aud) = claims.target_audience { + payload.extend_from_slice(b",\"target_audience\":"); + json::push_string(&mut payload, aud); + } + let _ = write!( + &mut payload, + ",\"iat\":{},\"exp\":{}}}", + claims.iat, claims.exp + ); + + let mut token = Vec::with_capacity(header.len() * 2 + payload.len() * 2 + 400); + b64url(&mut token, &header); + token.push(b'.'); + b64url(&mut token, &payload); + token +} + +/// `token` + `.signature`, RS256-signed with the PEM (PKCS#8 or PKCS#1) RSA +/// `private_key` from a service-account key file. +pub fn sign_rs256( + private_key_pem: &[u8], + mut token: Vec, +) -> Result, bun_boringssl::SignPemError> { + let signature = bun_boringssl::sign_pem_rs256(private_key_pem, &token)?; + token.push(b'.'); + b64url(&mut token, &signature); + Ok(token) +} + +/// The `exp` claim of a compact JWT, without verifying it (we only need to +/// know when to refresh a token Google handed us). +pub fn unverified_exp(jwt: &[u8]) -> Option { + let mut parts = bun_core::strings::split(jwt, b"."); + let _header = parts.next()?; + let payload = parts.next()?; + parts.next()?; + let mut decoded = vec![0u8; bun_base64::decode_lenient_len(payload.len())]; + let n = bun_base64::decode_lenient(&mut decoded, payload, true); + decoded.truncate(n); + json::parse(&decoded, |o| o.number(b"exp")) + .flatten() + .filter(|e| e.is_finite() && *e > 0.0) + .map(|e| e as u64) +} diff --git a/src/runtime/webcore/cloud/gcp/mod.rs b/src/runtime/webcore/cloud/gcp/mod.rs new file mode 100644 index 000000000000..9e64f163421f --- /dev/null +++ b/src/runtime/webcore/cloud/gcp/mod.rs @@ -0,0 +1,9 @@ +//! Google application default credentials: `Bun.GCPClient` / `Bun.gcp`. + +pub mod chain; +pub mod js; +pub mod jwt; +pub mod provider; + +pub use js::{ClientOptions, GCPClient, GcpFetchOptions}; +pub use provider::{TokenProvider, provider_for}; diff --git a/src/runtime/webcore/cloud/gcp/provider.rs b/src/runtime/webcore/cloud/gcp/provider.rs new file mode 100644 index 000000000000..a7b9ea8f52fd --- /dev/null +++ b/src/runtime/webcore/cloud/gcp/provider.rs @@ -0,0 +1,78 @@ +//! The cached Google token provider for one (request, source) pair; +//! resolution, waiting and background refresh are `cloud::flight`'s. + +use std::sync::Arc; + +use bun_jsc::JSGlobalObject; +use bun_jsc::virtual_machine::VirtualMachine; +use bun_s3_signing::ProviderError; + +use super::chain::{self, CredentialSource, GcpConfig, Token, TokenRequest}; +use crate::webcore::cloud::cache::CredentialCache; +use crate::webcore::cloud::flight::{self, Flights, Provider}; +use crate::webcore::cloud::io::{ChainFuture, Io}; + +/// Google access tokens live an hour; refresh a little ahead like the AWS side. +const REFRESH_WINDOW_SECONDS: u64 = 240; + +pub struct TokenProvider { + request: TokenRequest, + source: CredentialSource, + cache: CredentialCache, +} + +impl TokenProvider { + pub fn cached_usable(&self) -> Option> { + self.cache.usable() + } + + pub fn mark_stale(&self) { + self.cache.mark_stale() + } + + /// Usable now, keeping the background refresh going. + pub fn usable_kept_warm(self: &Arc, global: &JSGlobalObject) -> Option> { + let t = self.cache.usable()?; + flight::keep_warm(global, self); + Some(t) + } +} + +impl Provider for TokenProvider { + type Value = Token; + + fn cache(&self) -> &CredentialCache { + &self.cache + } + + fn begin(&self, global: &JSGlobalObject, io: Io) -> ChainFuture> { + chain::resolve( + GcpConfig::capture(global, &self.source), + self.request.clone(), + io, + ) + } + + fn flights() -> &'static mut Flights { + &mut crate::webcore::cloud::PerVm::get(VirtualMachine::get()).gcp + } + + fn interrupted() -> ProviderError { + ProviderError::new( + "ERR_GCP_CREDENTIALS", + b"token resolution was interrupted because the JavaScript VM is shutting down".to_vec(), + ) + } +} + +pub fn provider_for(request: TokenRequest, source: &CredentialSource) -> Arc { + let flights = TokenProvider::flights(); + if let Some(p) = flights.find(|p| p.request == request && p.source == *source) { + return p; + } + flights.insert(TokenProvider { + request, + source: source.clone(), + cache: CredentialCache::new(REFRESH_WINDOW_SECONDS), + }) +} diff --git a/src/runtime/webcore/cloud/io.rs b/src/runtime/webcore/cloud/io.rs new file mode 100644 index 000000000000..ab3b6ec6959f --- /dev/null +++ b/src/runtime/webcore/cloud/io.rs @@ -0,0 +1,449 @@ +//! Non-blocking I/O for the credential chains. A chain is an `async fn` +//! handed an [`Io`]; each `io.http(..).await` / `io.spawn(..).await` parks +//! the chain, the request runs on Bun's HTTP thread (or a helper thread, for +//! a `credential_process`), and [`drive`] resumes the chain on the JS thread +//! when the result is back. Nothing here blocks any thread on the network. + +use core::cell::RefCell; +use core::future::Future; +use core::pin::Pin; +use core::task::{Context, Poll, Waker}; +use std::rc::Rc; +use std::sync::Arc; + +use bun_jsc::JsResult; +use bun_jsc::job::{Completion, Job, JobContext, JsCallback, JsThread}; +use bun_jsc::virtual_machine::VirtualMachine; + +use crate::webcore::s3::simple_request::execute_raw_request; +pub use crate::webcore::s3::simple_request::{ + RawRequest as HttpRequest, RawResponse as HttpResponse, +}; + +#[derive(Debug)] +pub enum HttpError { + Transport(bun_http::Error), + NoResponse, + Shutdown, +} + +impl HttpError { + /// Cut short by a VM stop phase rather than answered by the endpoint. + pub fn is_interruption(&self) -> bool { + matches!( + self, + HttpError::Shutdown + | HttpError::Transport( + bun_http::Error::Aborted | bun_http::Error::AbortedBeforeConnecting + ) + ) + } +} + +impl core::fmt::Display for HttpError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + HttpError::Transport(bun_http::Error::Timeout) => f.write_str("request timed out"), + HttpError::Transport(e) => f.write_str(e.name()), + HttpError::NoResponse => f.write_str("connection closed without a response"), + HttpError::Shutdown => f.write_str("the JavaScript VM is shutting down"), + } + } +} + +pub struct SpawnRequest { + pub argv: Vec>, + pub windows_verbatim_arguments: bool, +} + +pub type SpawnResult = Result; + +pub enum SpawnError { + /// The VM's stop phase gave up waiting for the helper. + Interrupted, + Failed(Box<[u8]>), +} + +impl core::fmt::Display for SpawnError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + SpawnError::Interrupted => f.write_str("the JavaScript VM is shutting down"), + SpawnError::Failed(m) => write!(f, "{}", bstr::BStr::new(m)), + } + } +} + +type BlockingWork = Box Box + Send>; + +enum Op { + Http(HttpRequest), + Spawn(SpawnRequest), + Blocking(BlockingWork), +} + +enum OpResult { + Http(Result), + Spawn(SpawnResult), + Blocking(Option>), +} + +#[derive(Default)] +struct Slot { + op: Option, + result: Option, + /// An operation was cut short by a VM stop phase (teardown, test isolation). + interrupted: bool, +} + +/// The chain's handle for I/O. Cheap to clone; JS thread only. Its +/// operations never keep the process alive by themselves — whoever waits on +/// the chain does that (see `flight`), so a background refresh nobody is +/// waiting for cannot hold up exit. +#[derive(Clone, Default)] +pub struct Io(Rc>); + +/// One queued operation, as a future: the first poll hands the operation to +/// the driver, the next one (after the driver stored the result) yields it. +struct Pending<'a> { + io: &'a Io, + op: Option, +} + +impl Future for Pending<'_> { + type Output = OpResult; + fn poll(mut self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll { + let mut slot = self.io.0.borrow_mut(); + if let Some(op) = self.op.take() { + debug_assert!(slot.op.is_none() && slot.result.is_none()); + slot.op = Some(op); + return Poll::Pending; + } + match slot.result.take() { + Some(r) => Poll::Ready(r), + None => Poll::Pending, + } + } +} + +impl Io { + pub async fn http(&self, request: HttpRequest) -> Result { + match (Pending { + io: self, + op: Some(Op::Http(request)), + }) + .await + { + OpResult::Http(r) => r, + _ => unreachable!(), + } + } + + /// Run CPU-heavy or disk-touching `work` on the work pool. `None` if the + /// VM went away before it ran. + pub async fn blocking( + &self, + work: impl FnOnce() -> R + Send + 'static, + ) -> Option { + let work: BlockingWork = Box::new(move || Box::new(work())); + match (Pending { + io: self, + op: Some(Op::Blocking(work)), + }) + .await + { + OpResult::Blocking(r) => r.map(|b| *b.downcast::().expect("same R")), + _ => unreachable!(), + } + } + + pub async fn spawn(&self, request: SpawnRequest) -> SpawnResult { + match (Pending { + io: self, + op: Some(Op::Spawn(request)), + }) + .await + { + OpResult::Spawn(r) => r, + _ => unreachable!(), + } + } + + fn take_op(&self) -> Option { + self.0.borrow_mut().op.take() + } + + fn set_result(&self, result: OpResult) { + let mut slot = self.0.borrow_mut(); + slot.interrupted |= match &result { + OpResult::Http(Err(e)) => e.is_interruption(), + OpResult::Spawn(Err(SpawnError::Interrupted)) => true, + OpResult::Blocking(None) => true, + _ => false, + }; + slot.result = Some(result); + } + + /// Whether any operation so far was aborted from outside rather than + /// answered — the chain's conclusion is then not worth caching. + pub fn interrupted(&self) -> bool { + self.0.borrow().interrupted + } +} + +pub type ChainFuture = Pin>>; +pub type Done = Box JsResult<()>>; + +struct Task { + io: Io, + future: ChainFuture, + done: Done, +} + +/// Run `future` (which does its I/O through `io`) to completion on this JS +/// thread, then call `done`. Returns after the first suspension; if the +/// chain needs no I/O at all, `done` has already run by then. +pub fn drive(io: Io, future: ChainFuture, done: Done) -> JsResult<()> { + Box::new(Task { io, future, done }).step() +} + +impl Task { + fn step(mut self: Box) -> JsResult<()> { + let mut cx = Context::from_waker(Waker::noop()); + match self.future.as_mut().poll(&mut cx) { + Poll::Ready(value) => (self.done)(value), + Poll::Pending => match self.io.take_op() { + Some(Op::Http(request)) => self.start_http(request), + Some(Op::Spawn(request)) => self.start_spawn(request), + Some(Op::Blocking(work)) => self.start_blocking(work), + None => unreachable!("credential chain suspended without queuing I/O"), + }, + } + } + + fn resume(self: Box, result: OpResult) -> JsResult<()> { + self.io.set_result(result); + self.step() + } + + fn start_http(self: Box, mut request: HttpRequest) -> JsResult<()> { + if !VirtualMachine::get().script_allowed() { + return self.resume(OpResult::Http(Err(HttpError::Shutdown))); + } + request.holds_event_loop = false; + execute_raw_request( + request, + Box::new(move |result| { + let result = result.map_err(|e| match e { + Some(e) => HttpError::Transport(e), + None => HttpError::NoResponse, + }); + self.resume(OpResult::Http(result)) + }), + ); + Ok(()) + } + + fn start_spawn(self: Box, request: SpawnRequest) -> JsResult<()> { + let global = VirtualMachine::get().global(); + if !global.bun_vm().script_allowed() { + return self.resume(OpResult::Spawn(Err(SpawnError::Interrupted))); + } + Job::::schedule( + &global.js_thread(), + SpawnOff { + request, + env: super::env::Env::new(global).to_map(), + shared: Arc::default(), + }, + SpawnJs(Some(JsCallback(Box::new(move |result| { + self.resume(OpResult::Spawn(result)) + })))), + ); + Ok(()) + } + + fn start_blocking(self: Box, work: BlockingWork) -> JsResult<()> { + let global = VirtualMachine::get().global(); + if !global.bun_vm().script_allowed() { + return self.resume(OpResult::Blocking(None)); + } + Job::::schedule( + &global.js_thread(), + BlockingOff { + work: Some(work), + result: None, + }, + BlockingJs(Some(JsCallback(Box::new(move |result| { + self.resume(OpResult::Blocking(result)) + })))), + ); + Ok(()) + } +} + +// ── pool work ───────────────────────────────────────────────────────────── + +struct BlockingOff { + work: Option, + result: Option>, +} + +#[derive(bun_jsc::JsAffine)] +struct BlockingJs(Option>>>); + +impl Drop for BlockingJs { + fn drop(&mut self) { + if let Some(JsCallback(resume)) = self.0.take() { + let _ = resume(None); + } + } +} + +struct BlockingJob; + +impl JobContext for BlockingJob { + type OffThread = BlockingOff; + type Js = BlockingJs; + + const HOLDS_EVENT_LOOP: bool = false; + + fn run(off: &mut BlockingOff, done: Completion) -> Option> { + off.result = off.work.take().map(|w| w()); + Some(done) + } + + fn then(off: BlockingOff, mut js: BlockingJs, _cx: &JsThread<'_>) -> JsResult<()> { + match js.0.take() { + Some(JsCallback(resume)) => resume(off.result), + None => Ok(()), + } + } +} + +// ── credential_process, off the JS thread ───────────────────────────────── + +struct SpawnOff { + request: SpawnRequest, + env: bun_sys::EnvMap, + shared: Arc, +} + +/// What the pool worker, the helper thread and the VM's stop phase share. +#[derive(Default)] +struct SpawnShared { + /// The job's completion while the helper runs; whoever takes it — + /// the helper thread when the child exits, or the stop phase — finishes + /// the job. + done: bun_threading::Guarded>>, + result: bun_threading::Guarded>, + cancelled: core::sync::atomic::AtomicBool, +} + +#[derive(bun_jsc::JsAffine)] +struct SpawnJs(Option>); + +impl Drop for SpawnJs { + /// Still holding the callback only when the job is released unrun at + /// teardown: the chain must hear back exactly once. + fn drop(&mut self) { + if let Some(JsCallback(resume)) = self.0.take() { + let _ = resume(Err(SpawnError::Interrupted)); + } + } +} + +struct SpawnJob; + +impl JobContext for SpawnJob { + type OffThread = SpawnOff; + type Js = SpawnJs; + + const CANCELLABLE: bool = true; + + const HOLDS_EVENT_LOOP: bool = false; + + /// A helper blocked on a prompt must not hold up VM teardown: hand the + /// job back now; the helper thread finds `done` gone when the child + /// eventually exits and just drops its output. + fn canceller(off: &SpawnOff) -> Option { + let shared = Arc::clone(&off.shared); + Some(Box::new(move || { + shared + .cancelled + .store(true, core::sync::atomic::Ordering::Relaxed); + if let Some(done) = shared.done.lock().take() { + *shared.result.lock() = Some(Err(SpawnError::Interrupted)); + done.finish(); + } + })) + } + + fn run(off: &mut SpawnOff, done: Completion) -> Option> { + let shared = Arc::clone(&off.shared); + if shared.cancelled.load(core::sync::atomic::Ordering::Relaxed) { + *shared.result.lock() = Some(Err(SpawnError::Interrupted)); + return Some(done); + } + // A credential_process may take a while (or prompt and hang); wait + // for it on a thread of its own rather than parking a pool worker. + let argv = core::mem::take(&mut off.request.argv); + let env = core::mem::take(&mut off.env); + let verbatim = off.request.windows_verbatim_arguments; + *shared.done.lock() = Some(done); + if shared.cancelled.load(core::sync::atomic::Ordering::Relaxed) { + // The stop phase may already have taken `done` and finished the job. + let done = shared.done.lock().take()?; + *shared.result.lock() = Some(Err(SpawnError::Interrupted)); + return Some(done); + } + let for_thread = Arc::clone(&shared); + let spawned = std::thread::Builder::new() + .name("credential_process".into()) + .spawn(move || { + bun_core::output::Source::configure_thread(); + let shared = for_thread; + let argv: Vec<&[u8]> = argv.iter().map(|a| &**a).collect(); + let result = bun_spawn::run(bun_spawn::RunOptions { + argv: &argv, + env_map: &env, + windows_verbatim_arguments: verbatim, + }) + .map_err(|e| { + SpawnError::Failed( + format!("could not start \"{}\": {e}", bstr::BStr::new(argv[0])) + .into_bytes() + .into_boxed_slice(), + ) + }); + if let Some(done) = shared.done.lock().take() { + *shared.result.lock() = Some(result); + done.finish(); + } + }); + match spawned { + Ok(_) => None, + Err(e) => { + let done = shared.done.lock().take()?; + *shared.result.lock() = Some(Err(SpawnError::Failed( + format!("could not start a thread: {e}") + .into_bytes() + .into_boxed_slice(), + ))); + Some(done) + } + } + } + + fn then(off: SpawnOff, mut js: SpawnJs, _cx: &JsThread<'_>) -> JsResult<()> { + let result = off + .shared + .result + .lock() + .take() + .unwrap_or(Err(SpawnError::Interrupted)); + match js.0.take() { + Some(JsCallback(resume)) => resume(result), + None => Ok(()), + } + } +} diff --git a/src/runtime/webcore/cloud/json.rs b/src/runtime/webcore/cloud/json.rs new file mode 100644 index 000000000000..08b32fcbd8e3 --- /dev/null +++ b/src/runtime/webcore/cloud/json.rs @@ -0,0 +1,59 @@ +//! Small credential documents (IMDS/ECS/SSO/process output, GCP tokens) read +//! through Bun's JSON parser in a throwaway arena; callers copy out strings. + +use bun_ast::E; +use bun_ast::expr::Data; + +#[derive(Clone, Copy)] +pub struct Obj<'a>(pub &'a E::ObjectJSON); + +impl<'a> Obj<'a> { + pub fn str(self, key: &[u8]) -> Option> { + self.0 + .get(key)? + .as_str() + .filter(|s| !s.is_empty()) + .map(Box::from) + } + + pub fn number(self, key: &[u8]) -> Option { + match self.0.get(key)? { + E::JsonValue::Number(n) => Some(n.value()), + E::JsonValue::String(s) => core::str::from_utf8(s.slice()).ok()?.trim().parse().ok(), + _ => None, + } + } + + pub fn object(self, key: &[u8]) -> Option> { + self.0.get(key)?.as_object().map(Obj) + } +} + +/// Parses `body` as a JSON object and maps it through `read`; `None` if it is +/// not one. +pub fn parse(body: &[u8], read: impl FnOnce(Obj<'_>) -> R) -> Option { + let body = body.trim_ascii(); + if body.is_empty() || body.len() > i32::MAX as usize { + return None; + } + let arena = bun_alloc::Arena::default(); + let mut ast_memory_allocator = bun_ast::ASTMemoryAllocator::borrowing(&arena); + let _ast_scope = ast_memory_allocator.enter(); + let mut log = bun_ast::Log::init(); + let source = bun_ast::Source::init_path_string(b"credentials.json", body); + let Ok(bun_ast::Expr { + data: Data::EObjectJSON(root), + .. + }) = bun_parsers::json::parse_json_into_arena(&source, &mut log, &arena) + else { + return None; + }; + let value = E::JsonValue::Object(root); + let obj = value.as_object()?; + Some(read(Obj(obj))) +} + +/// Append `s` as a JSON string literal (quotes included). +pub fn push_string(out: &mut Vec, s: &[u8]) { + let _ = bun_core::fmt::encode_json_string(&mut bun_core::fmt::VecWriter(out), s); +} diff --git a/src/runtime/webcore/cloud/mod.rs b/src/runtime/webcore/cloud/mod.rs new file mode 100644 index 000000000000..b0d4140b8821 --- /dev/null +++ b/src/runtime/webcore/cloud/mod.rs @@ -0,0 +1,53 @@ +//! Cloud credentials without an SDK: the AWS default credential provider +//! chain + SigV4 (`Bun.s3`, `fetch("s3://…")`, `Bun.aws` / `Bun.AWSClient`) +//! and Google application default credentials (`Bun.gcp` / `Bun.GCPClient`). +//! `flight`/`io`/`cache`/`json`/`env` are the shared plumbing. + +pub mod aws; +pub mod cache; +pub mod env; +pub mod flight; +pub mod gcp; +pub mod io; +pub mod json; + +/// A read that failed because nothing is at the path (as opposed to +/// something being there that cannot be read). +pub(crate) fn not_found(e: &bun_sys::Error) -> bool { + matches!(e.get_errno(), bun_sys::E::ENOENT | bun_sys::E::ENOTDIR) +} + +/// `application/x-www-form-urlencoded` body from key/value pairs. +pub(crate) fn form_encode(out: &mut Vec, pairs: &[(&[u8], &[u8])]) { + for (i, (k, v)) in pairs.iter().enumerate() { + if i > 0 { + out.push(b'&'); + } + bun_s3_signing::sigv4::uri_encode_into(out, k, false); + out.push(b'='); + bun_s3_signing::sigv4::uri_encode_into(out, v, false); + } +} + +/// Per-VM state (lives in `RareData`, dropped with the VM): the credential +/// providers, the resolutions currently in flight and their waiters, for +/// both clouds. A Worker has its own, so its own `env` yields its own +/// credentials. +#[derive(Default)] +pub(crate) struct PerVm { + pub aws: flight::Flights, + pub gcp: flight::Flights, +} + +impl PerVm { + /// JS thread only. Callers keep the borrow short and never hold it across + /// a call that may re-enter (continuations, JS). + pub(crate) fn get(vm: &bun_jsc::virtual_machine::VirtualMachine) -> &mut PerVm { + vm.as_mut() + .rare_data() + .cloud_credentials + .get_or_insert_with(|| Box::new(PerVm::default())) + .downcast_mut::() + .expect("RareData.cloud_credentials holds cloud::PerVm") + } +} diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index a08f57f10366..ec07e7f669dd 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -102,18 +102,44 @@ fn ssl_config_intern_for_http(config: SSLConfig) -> http::ssl_config::SharedPtr /// `bun_dotenv::S3Credentials` POD mirror. The dotenv crate (T2) cannot name /// `bun_s3_signing` types (would be an upward dep), so the conversion lives at /// the call site here in T6. -pub(crate) fn s3_credentials_from_env( - env: &bun_dotenv::S3Credentials, -) -> bun_s3_signing::S3Credentials { - bun_s3_signing::S3Credentials::new_value( - env.access_key_id.clone(), - env.secret_access_key.clone(), +pub(crate) fn s3_credentials_from_env(global: &JSGlobalObject) -> bun_s3_signing::S3Credentials { + // As in the AWS SDKs, `AWS_PROFILE` selects a profile even when + // `AWS_ACCESS_KEY_ID`-style variables are also exported; Bun's own + // `S3_*` variables stay explicit configuration and always apply. + let profile_in_env = crate::webcore::cloud::env::Env::new(global) + .get(b"AWS_PROFILE") + .is_some_and(|p| !p.is_empty()); + let loader = global.bun_vm().as_mut().transpiler.env_mut(); + let profile_selected = + profile_in_env && loader.get(b"S3_ACCESS_KEY_ID").is_none_or(<[u8]>::is_empty); + let env = loader.get_s3_credentials(); + let mut credentials = bun_s3_signing::S3Credentials::new_value( + if profile_selected { + Box::default() + } else { + env.access_key_id.clone() + }, + if profile_selected { + Box::default() + } else { + env.secret_access_key.clone() + }, env.region.clone(), env.endpoint.clone(), env.bucket.clone(), - env.session_token.clone(), + if profile_selected { + Box::default() + } else { + env.session_token.clone() + }, env.insecure_http, - ) + ); + if !credentials.has_static_credentials() { + // No keys in the environment: fall back to the AWS default chain + // (shared config/SSO/process/web identity → container → IMDS). + credentials.provider = Some(crate::webcore::aws::default_provider(None)); + } + credentials } /// RAII guard for the `+1` `AbortSignal` ref taken in `extract_signal`, @@ -338,7 +364,10 @@ impl StringOrURL { /// Public entry point for `Bun.fetch` - validates body on GET/HEAD #[bun_jsc::host_fn(export = "Bun__fetch")] fn bun_fetch(ctx: &JSGlobalObject, callframe: &CallFrame) -> JsResult { - reject_on_exception(ctx, fetch_impl::(ctx, callframe)) + reject_on_exception( + ctx, + fetch_impl::(ctx, callframe.arguments(), false, FetchAuth::None), + ) } /// WHATWG fetch step 3: an exception thrown while processing `input`/`init` @@ -385,10 +414,79 @@ enum URLType { // fetchImpl — shared implementation // ────────────────────────────────────────────────────────────────────────── -/// Shared implementation of fetch -fn fetch_impl( +/// Resolve `provider` (I/O on the HTTP thread), then run the same `fetch` +/// call again (its arguments GC-protected meanwhile) and settle the returned +/// promise with the second call's result. +fn defer_until_credentials< + const ALLOW_GET_BODY: bool, + P: crate::webcore::cloud::flight::Provider, +>( + global_this: &JSGlobalObject, + arguments: &[JSValue], + provider: &std::sync::Arc

, + is_s3: bool, + auth: FetchAuth, +) -> JsResult { + let protected: Vec = arguments + .iter() + .map(|v| jsc::job::Protected::new(*v)) + .collect(); + crate::webcore::cloud::flight::promise( + global_this, + provider, + if is_s3 { + |global, err| { + s3::s3_error_to_js( + &s3::Error::S3Error { + code: err.s3_code().as_bytes(), + message: &err.message, + }, + global, + None, + ) + } + } else { + crate::webcore::aws::fetch_signing::provider_error_to_js + }, + move |global, _credentials| { + let argv: Vec = protected.iter().map(|p| p.value()).collect(); + reject_on_exception( + global, + fetch_impl::(global, &argv, true, auth), + ) + }, + ) +} + +/// Which ambient-credential flavour of fetch this is, with the client +/// instance's defaults. +#[derive(Clone)] +pub(crate) enum FetchAuth { + /// `fetch()` + None, + /// `awsClient.fetch()`: SigV4-sign the request. + Aws(std::sync::Arc), + /// `gcpClient.fetch()`: attach a Google bearer token. + Gcp(std::sync::Arc), +} + +/// `Bun.aws.fetch` / `Bun.gcp.fetch` entry point. +pub(crate) fn fetch_with_auth( ctx: &JSGlobalObject, callframe: &CallFrame, + auth: FetchAuth, +) -> JsResult { + reject_on_exception( + ctx, + fetch_impl::(ctx, callframe.arguments(), false, auth), + ) +} + +fn fetch_impl( + ctx: &JSGlobalObject, + arguments: &[JSValue], + credentials_ready: bool, + auth: FetchAuth, ) -> JsResult { jsc::mark_binding(); let global_this = ctx; @@ -400,7 +498,7 @@ fn fetch_impl( let mut upgraded_connection = false; let mut forced_protocol: Option = None; - if callframe.arguments_count() == 0 { + if arguments.is_empty() { let err = ctx.to_type_error( jsc::ErrorCode::MISSING_ARGS, format_args!("{FETCH_ERROR_NO_ARGS}"), @@ -419,7 +517,7 @@ fn fetch_impl( // immutable borrow of `vm` for the rest of the function. let vm_verbose_fetch = vm.get_verbose_fetch(); - let mut args = jsc::ArgumentsSlice::init(vm, callframe.arguments()); + let mut args = jsc::ArgumentsSlice::init(vm, arguments); let first_arg = args.next_eat().unwrap(); @@ -506,7 +604,7 @@ fn fetch_impl( // If it's NOT a Request or a subclass of Request, treat the first argument as a URL. let url_str_optional = if first_arg.as_::().is_none() { - StringOrURL::from_js(first_arg, global_this)? + StringOrURL::from_js(first_arg, global_this)?.map(bun_core::OwnedString::new) } else { None }; @@ -524,6 +622,68 @@ fn fetch_impl( break 'brk None; }; + // `Bun.aws.fetch` / `Bun.gcp.fetch`: signing / token options sit at the + // top level of the init dict(s), later ones winning like every other + // field (a credentials pair/triple counts as one field). + let inits = [request_init_object, options_object].map(|v| v.unwrap_or(JSValue::UNDEFINED)); + let aws_sign: Option = match &auth { + FetchAuth::Aws(base) => Some(base.with_overrides(global_this, &inits)?), + _ => None, + }; + let gcp_auth: Option = match &auth { + FetchAuth::Gcp(base) => Some( + crate::webcore::cloud::gcp::GcpFetchOptions::from_js_with_base( + global_this, + &inits, + base, + )?, + ), + _ => None, + }; + + // What the caller's `signal` already says, without taking a ref: used to + // decide whether waiting for credentials may precede the real + // `extract_signal` (same precedence: `init.signal`, even null, wins). + enum SignalNow { + Live, + Aborted(JSValue), + Invalid, + } + macro_rules! signal_now { + () => { + 'now: { + for obj in [ + options_object.unwrap_or_default(), + request_init_object.unwrap_or_default(), + ] { + if !obj.is_empty() + && let Some(sig) = obj.get(global_this, "signal")? + { + break 'now match AbortSignal::from_js(sig) { + Some(s) => { + let s = bun_opaque::opaque_deref(s); + if s.aborted() { + SignalNow::Aborted(s.js_reason(global_this)) + } else { + SignalNow::Live + } + } + None if sig.is_null() => SignalNow::Live, + None => SignalNow::Invalid, + }; + } + } + if let Some(req) = request_mut!() + && let Some(sig) = req.abort_signal() + && sig.aborted() + { + break 'now SignalNow::Aborted(sig.js_reason(global_this)); + } + SignalNow::Live + } + }; + } + // Every arm carries a +1 (`from_js`/`dupe_ref`/`StringOrURL::from_js`). // `bun_core::String` is `Copy` // with NO `Drop`, so wrap in `OwnedString` for the scope-exit deref — @@ -531,26 +691,29 @@ fn fetch_impl( // is a substring sharing an `ExternalStringImpl` (e.g. a slice of a // `TextDecoder.decode()` result), that leaked +1 transitively pins the // external buffer past `~VM`. - let url_str: bun_core::OwnedString = bun_core::OwnedString::new('extract_url: { + let url_str: bun_core::OwnedString = 'extract_url: { if let Some(str) = url_str_optional { break 'extract_url str; } if let Some(req) = request_mut!() { let _ = req.ensure_url(); // bun.handleOom — aborts on OOM - break 'extract_url req.url.get().dupe_ref(); + break 'extract_url bun_core::OwnedString::new(req.url.get().dupe_ref()); } if let Some(request_init) = request_init_object { if let Some(url_) = request_init.fast_get(global_this, jsc::BuiltinName::Url)? { if !url_.is_undefined() { - break 'extract_url BunString::from_js(url_, global_this)?; + break 'extract_url bun_core::OwnedString::new(BunString::from_js( + url_, + global_this, + )?); } } } - break 'extract_url BunString::empty(); - }); + break 'extract_url bun_core::OwnedString::new(BunString::empty()); + }; if global_this.has_exception() { return Ok(JSValue::ZERO); @@ -592,6 +755,65 @@ fn fetch_impl( return Ok(data_url_response(data_url, global_this)); } + // `Bun.aws.fetch("/path?query", { service })`: the standard endpoint for + // that service in the resolved region. + let url_str = if let Some(aws) = aws_sign + .as_ref() + .filter(|_| url_str.has_prefix_comptime(b"/")) + { + let path = url_str.to_utf8_without_ref(); + match aws.default_endpoint(global_this) { + Ok(origin) => bun_core::OwnedString::new(BunString::create_format(format_args!( + "{}{}", + bstr::BStr::new(&origin), + bstr::BStr::new(path.slice()) + ))), + Err(crate::webcore::aws::sign_options::EndpointError::RegionPending(provider)) + if !credentials_ready => + { + // The region may come from the profile: resolve credentials + // first, then run this call again — unless the signal has + // already decided the outcome. + let early = match signal_now!() { + SignalNow::Live => { + return defer_until_credentials::( + global_this, + arguments, + &provider, + false, + auth, + ); + } + SignalNow::Aborted(reason) => reason, + SignalNow::Invalid => ctx.to_type_error( + jsc::ErrorCode::INVALID_ARG_TYPE, + format_args!("signal is not of type AbortSignal."), + ), + }; + return Ok( + JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm( + global_this, + early, + ), + ); + } + Err(err) => { + let err = ctx.to_type_error( + jsc::ErrorCode::INVALID_URL, + format_args!("Bun.aws.fetch() {err}"), + ); + return Ok( + JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm( + global_this, + err, + ), + ); + } + } + } else { + url_str + }; + // `ZigURL::from_string` returns `OwnedURL` (owns href buffer); we // immediately move that buffer into `url_proxy_buffer` and re-parse `url` to // borrow it. @@ -642,6 +864,116 @@ fn fetch_impl( } .unwrap_or(Method::GET); + let client_name = match &auth { + FetchAuth::None => None, + FetchAuth::Aws(_) => Some("AWSClient"), + FetchAuth::Gcp(_) => Some("GCPClient"), + }; + if let Some(client_name) = client_name + && url.is_s3() + { + let err = global_this.to_type_error( + jsc::ErrorCode::INVALID_ARG_VALUE, + format_args!( + "{client_name}.fetch() takes an https:// URL; for s3:// URLs use fetch() with the s3 option or Bun.s3" + ), + ); + return Ok( + JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm( + global_this, + err, + ), + ); + } + + // Credentials for `s3://` URLs: env, then the `s3` option bag. + let mut s3_credentials: Option = if url.is_s3() { + let env_creds = s3_credentials_from_env(global_this); + let mut credentials_with_options = s3::S3CredentialsWithOptions { + credentials: env_creds, + options: Default::default(), + acl: None, + storage_class: None, + ..Default::default() + }; + if let Some(options) = options_object { + if let Some(s3_options) = options.get_truthy(global_this, "s3")? { + let s3_options: JSValue = s3_options; + if s3_options.is_object() { + s3_options.ensure_still_alive(); + use crate::webcore::s3_client::S3CredentialsExt as _; + credentials_with_options = ::get_credentials_with_options( + &credentials_with_options.credentials, + Default::default(), + Some(s3_options), + None, + None, + false, + global_this, + )?; + } + } + } + Some(credentials_with_options) + } else { + None + }; + + // Ambient credentials (profile / SSO / container / metadata) resolve + // asynchronously; once they are cached, run this same call again. A + // signal that is already aborted skips this so the rejection stays + // synchronous. + if !credentials_ready { + let gcp_pending = gcp_auth + .as_ref() + .filter(|g| g.needs_resolution()) + .map(|g| std::sync::Arc::clone(&g.provider)); + let aws_pending = aws_sign + .as_ref() + .and_then(|a| { + a.needs_credentials_resolution() + .then(|| a.provider().cloned()) + .flatten() + }) + .or_else(|| { + s3_credentials.as_ref().and_then(|c| { + c.credentials + .needs_credentials_resolution() + .then(|| { + c.credentials + .provider + .as_ref() + .and_then(crate::webcore::aws::provider::as_default) + }) + .flatten() + }) + }); + if gcp_pending.is_some() || aws_pending.is_some() { + let already_settled = !matches!(signal_now!(), SignalNow::Live); + if !already_settled { + drop(s3_credentials.take()); + if let Some(provider) = gcp_pending { + return defer_until_credentials::( + global_this, + arguments, + &provider, + false, + auth, + ); + } + if let Some(provider) = aws_pending { + return defer_until_credentials::( + global_this, + arguments, + &provider, + url.is_s3(), + auth, + ); + } + } + } + } + // "decompress: boolean" disable_decompression = 'extract_disable_decompression: { let objects_to_try = [ @@ -923,6 +1255,12 @@ fn fetch_impl( } } + // A signature is bound to the host and path it was computed for, so a + // signed request cannot meaningfully follow a redirect (the SDKs never + // do). Surface the 3xx unless the caller asked for something else. + if aws_sign.is_some() { + break 'extract_redirect_type FetchRedirect::Manual; + } break 'extract_redirect_type redirect_type; }; @@ -1725,7 +2063,11 @@ fn fetch_impl( // An explicit `compress` request always wins over the sendfile // heuristic — otherwise the same `Bun.file()` body would compress // over https/proxy/<32 KiB/Windows but silently not over plain http. - if proxy.is_none() && compress.is_none() && http::SendFile::is_eligible(&url) { + if proxy.is_none() + && compress.is_none() + && aws_sign.is_none() + && http::SendFile::is_eligible(&url) + { 'use_sendfile: { let stat: bun_sys::Stat = match bun_sys::fstat(opened_fd) { Ok(result) => result, @@ -1840,6 +2182,7 @@ fn fetch_impl( if let Some(compress_opt) = &compress && let HTTPRequestBody::AnyBlob(_) = &body && !url.is_s3() + && aws_sign.is_none() { let already_has_encoding = headers .as_ref() @@ -1856,45 +2199,98 @@ fn fetch_impl( compress = None; } - if url.is_s3() { - // get ENV config — `Transpiler::env_mut` is the safe accessor for the - // process-singleton dotenv loader (set during init). - let env_creds = s3_credentials_from_env( - global_this - .bun_vm() - .as_mut() - .transpiler - .env_mut() - .get_s3_credentials(), - ); - let mut credentials_with_options = s3::S3CredentialsWithOptions { - credentials: env_creds, - options: Default::default(), - acl: None, - storage_class: None, - ..Default::default() + if let Some(gcp) = &gcp_auth { + let token = match gcp.provider.usable_kept_warm(global_this) { + Some(t) => Ok(t), + // The first pass resolves before re-entering, so this only + // happens if the token expired in between. + None => Err(std::sync::Arc::new(bun_s3_signing::ProviderError::new( + "ERR_GCP_CREDENTIALS", + b"Google Cloud token expired while the request was being prepared; retry".to_vec(), + ))), }; - // `defer credentialsWithOptions.deinit()` → Drop. - - if let Some(options) = options_object { - if let Some(s3_options) = options.get_truthy(global_this, "s3")? { - let s3_options: JSValue = s3_options; - if s3_options.is_object() { - s3_options.ensure_still_alive(); - use crate::webcore::s3_client::S3CredentialsExt as _; - credentials_with_options = ::get_credentials_with_options( - &credentials_with_options.credentials, - Default::default(), - Some(s3_options), - None, - None, - false, + match token { + Ok(t) => { + if headers + .as_ref() + .and_then(|h| h.get(b"authorization")) + .is_some() + { + let err = global_this.to_type_error( + jsc::ErrorCode::INVALID_ARG_VALUE, + format_args!("Bun.gcp.fetch() sets the \"Authorization\" header itself; remove it from headers"), + ); + body.detach(); + return Ok( + JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm( + global_this, + err, + ), + ); + } + let h = headers.get_or_insert_with(Headers::default); + let mut value = Vec::with_capacity(7 + t.token.len()); + value.extend_from_slice(b"Bearer "); + value.extend_from_slice(&t.token); + h.append(b"Authorization", &value); + if let Some(q) = &t.quota_project_id { + if h.get(b"x-goog-user-project").is_none() { + h.append(b"x-goog-user-project", q); + } + } + } + Err(err) => { + body.detach(); + return Ok( + JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm( global_this, - )?; + crate::webcore::aws::fetch_signing::provider_error_to_js(global_this, &err), + ), + ); + } + } + } + + if let Some(aws) = aws_sign.as_ref().filter(|_| !url.is_s3()) { + use crate::webcore::aws::fetch_signing::{Body as SignBody, Signed, sign_fetch_request}; + let sign_body = match &body { + HTTPRequestBody::AnyBlob(_) => SignBody::Bytes(body.slice()), + _ => SignBody::Streaming, + }; + match sign_fetch_request(global_this, aws, method, &url, &mut headers, sign_body) { + Ok(Signed::Headers) => {} + Ok(Signed::Url(new_url)) => { + let old_buffer = core::mem::take(&mut url_proxy_buffer); + if let Some(proxy_) = &proxy { + let mut buffer = Vec::with_capacity(new_url.len() + proxy_.href.len()); + buffer.extend_from_slice(&new_url); + buffer.extend_from_slice(proxy_.href); + url_proxy_buffer = buffer; + url = parse_url_detached!(&url_proxy_buffer[0..new_url.len()]); + proxy = Some(parse_url_detached!(&url_proxy_buffer[new_url.len()..])); + } else { + url_proxy_buffer = new_url.into_vec(); + url = parse_url_detached!(&url_proxy_buffer[..]); } + drop(old_buffer); + } + Err(message) => { + let err = global_this.to_type_error( + jsc::ErrorCode::INVALID_ARG_VALUE, + format_args!("Bun.aws.fetch() could not sign the request: {message}"), + ); + body.detach(); + return Ok( + JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm( + global_this, + err, + ), + ); } } + } + if let Some(credentials_with_options) = s3_credentials.take() { if let HTTPRequestBody::ReadableStream(ref readable_stream) = body { // we cannot direct stream to s3 we need to use multi part upload // `defer body.ReadableStream.deinit()` → Drop on `body` scope exit. diff --git a/src/runtime/webcore/s3/client.rs b/src/runtime/webcore/s3/client.rs index a6b43286b9a6..00bb51cb0a81 100644 --- a/src/runtime/webcore/s3/client.rs +++ b/src/runtime/webcore/s3/client.rs @@ -25,6 +25,7 @@ pub use bun_s3_signing::error as Error; pub mod error_jsc; pub(crate) use error_jsc::S3ErrorJsc; pub(crate) use error_jsc::get_js_sign_error; +pub(crate) use error_jsc::resolve_ambient_credentials_or_throw; pub(crate) use error_jsc::s3_error_to_js; pub(crate) use error_jsc::throw_sign_error; @@ -255,6 +256,43 @@ pub(crate) fn list_objects( let _ = search_params.append_fmt(format_args!("&start-after={}", bstr::BStr::new(encoded))); // OOM/capacity: fire-and-forget } + if this.needs_credentials_resolution() { + let provider = this.provider.clone().expect("needs_credentials_resolution"); + let credentials = this.clone(); + let proxy_owned: Option> = proxy_url.map(Box::from); + return crate::webcore::aws::resolve_shared_async( + VirtualMachine::get().global(), + &provider, + Box::new(move |result| match result { + Err(err) => s3_simple_request::Callback::ListObjects(callback) + .fail_credentials(&err, callback_context), + Ok(_) => send_list_objects( + &credentials, + search_params, + callback, + callback_context, + proxy_owned.as_deref(), + ), + }), + ); + } + send_list_objects(this, search_params, callback, callback_context, proxy_url) +} + +fn send_list_objects( + this: &S3Credentials, + search_params: Vec, + callback: fn(S3ListObjectsResult, *mut c_void) -> JsResult<()>, + callback_context: *mut c_void, + proxy_url: Option<&[u8]>, +) -> JsResult<()> { + if !VirtualMachine::get().script_allowed() { + return s3_simple_request::Callback::ListObjects(callback).fail( + b"ERR_S3_VM_SHUTDOWN", + b"The JavaScript VM that owns this request is shutting down", + callback_context, + ); + } let result = match this.sign_request::( &bun_s3_signing::SignOptions { path: b"", @@ -292,89 +330,22 @@ pub(crate) fn list_objects( let headers = bun_http::Headers::from_pico_http_headers(result.headers()); - let task_ptr = bun_core::heap::into_raw(Box::new(S3HttpSimpleTask { - // Written below via `MaybeUninit::write` before any read. - http: core::mem::MaybeUninit::uninit(), - sign_result: result, - callback_context, - callback: s3_simple_request::Callback::ListObjects(callback), + let task_ptr = S3HttpSimpleTask::create( + result, + s3_simple_request::Completion::S3 { + callback: s3_simple_request::Callback::ListObjects(callback), + context: callback_context, + }, headers, - http_ticket: None, - response_buffer: MutableString::default(), - result: bun_http::HTTPClientResult::default(), - concurrent_task: Default::default(), - proxy_url: Box::default(), - body: Box::default(), - poll_ref: bun_io::KeepAlive::init(), - signal_store: Default::default(), - })); - // SAFETY: just allocated, non-null - let task = unsafe { &mut *task_ptr }; - - task.poll_ref.ref_(bun_io::js_vm_ctx()); - - let proxy = proxy_url.unwrap_or(b""); - task.proxy_url = if !proxy.is_empty() { - Box::<[u8]>::from(proxy) - } else { - Box::<[u8]>::default() - }; - - // SAFETY: lifetime extension — `url`, `headers_buf`, and `proxy_url` borrow from - // heap-allocated fields of `*task` which the task outlives. AsyncHTTP::init wants - // `'static` borrows because the HTTP thread reads them concurrently; they remain valid - // until `task` is dropped in `on_response`. - let url = bun_url::URL::parse(unsafe { bun_ptr::detach_lifetime_ref(&*task.sign_result.url) }); - // SAFETY: same lifetime-extension invariant as `url` above — `task.headers.buf` is - // heap-owned by `*task` and outlives the AsyncHTTP request. - let headers_buf: &'static [u8] = - unsafe { bun_ptr::detach_lifetime(task.headers.buf.as_slice()) }; - let http_proxy = if !task.proxy_url.is_empty() { - // SAFETY: same lifetime-extension invariant as `url` above — `task.proxy_url` is - // heap-owned by `*task` and outlives the AsyncHTTP request. - Some(bun_url::URL::parse(unsafe { - bun_ptr::detach_lifetime_ref(&*task.proxy_url) - })) - } else { - None - }; - // JS thread (request setup): read options from the current VM. - let vm = VirtualMachine::get(); - - task.http.write(bun_http::AsyncHTTP::init( + proxy_url, + Box::default(), + ); + s3_simple_request::send( + task_ptr, bun_http::Method::GET, - url, - task.headers.entries.clone().expect("OOM"), - headers_buf, - b"", - bun_http::HTTPClientResultCallback::new_with_release::( - task_ptr, - // SAFETY: `task_ptr` is the heap-allocated task registered above; the - // HTTP thread invokes this with that exact pointer. - S3HttpSimpleTask::http_callback, - S3HttpSimpleTask::release_at_shutdown, - ), bun_http::FetchRedirect::Follow, - bun_http::async_http::Options { - http_proxy, - verbose: Some(vm.get_verbose_fetch()), - reject_unauthorized: Some(vm.get_tls_reject_unauthorized()), - signals: Some(task.signal_store.to()), - ..Default::default() - }, - )); - - // queue http request - bun_http::http_thread::init(&Default::default()); - let mut batch = bun_threading::thread_pool::Batch::default(); - // SAFETY: `http` was initialised by `task.http.write(...)` immediately above. - unsafe { task.http.assume_init_mut() }.schedule(&mut batch); - // Out on the HTTP thread until its final callback: the VM aborts it at - // teardown (registry) and waits for it (the ticket). - task.http_ticket = Some(VirtualMachine::get().ticket()); - crate::jsc_hooks::ActiveHandle::S3Request(core::ptr::NonNull::new(task_ptr).expect("task")) - .register(); - bun_http::HTTPThread::schedule(batch); + Default::default(), + ); Ok(()) } @@ -1494,6 +1465,64 @@ pub(crate) fn readable_stream( }, )); + if this.needs_credentials_resolution() { + let provider = this.provider.clone().expect("needs_credentials_resolution"); + let credentials = this.clone(); + let path: Box<[u8]> = Box::from(path); + let proxy: Option> = proxy_url.map(Box::from); + crate::webcore::aws::resolve_shared_async( + global_this, + &provider, + Box::new(move |result| { + // SAFETY: the wrapper is only freed by a `has_more == false` + // callback, which cannot have happened before a task exists. + let cancelled = unsafe { + (*wrapper) + .readable_stream_ref + .get(&(*wrapper).global) + .is_none() + }; + match result { + Err(err) => S3DownloadStreamWrapper::opaque_callback( + &MutableString::default(), + false, + Some(Error::S3Error { + code: err.s3_code().as_bytes(), + message: &err.message, + }), + wrapper.cast::(), + ), + Ok(_) if cancelled || !VirtualMachine::get().script_allowed() => { + S3DownloadStreamWrapper::opaque_callback( + &MutableString::default(), + false, + None, + wrapper.cast::(), + ) + } + Ok(_) => { + let task = download_stream( + &credentials, + &path, + offset, + size, + proxy.as_deref(), + request_payer, + S3DownloadStreamWrapper::opaque_callback, + wrapper.cast::(), + ); + if !task.is_null() { + // SAFETY: as below. + unsafe { (*wrapper).task = task }; + } + } + } + Ok(()) + }), + )?; + return Ok(readable_value); + } + let task = download_stream( this, path, diff --git a/src/runtime/webcore/s3/credentials_jsc.rs b/src/runtime/webcore/s3/credentials_jsc.rs index 7b249313929b..a8f398035295 100644 --- a/src/runtime/webcore/s3/credentials_jsc.rs +++ b/src/runtime/webcore/s3/credentials_jsc.rs @@ -92,6 +92,15 @@ pub(crate) fn get_credentials_with_options( if let Some(opts) = options { if opts.is_object() { + if let Some(utf8) = get_truthy_string_utf8(opts, global_object, b"profile", true)? { + // An explicit profile is more specific than ambient env keys. + new_credentials.credentials.access_key_id = Box::default(); + new_credentials.credentials.secret_access_key = Box::default(); + new_credentials.credentials.session_token = Box::default(); + new_credentials.credentials.provider = + Some(crate::webcore::aws::default_provider(Some(utf8.slice()))); + new_credentials.changed_credentials = true; + } if let Some(utf8) = get_truthy_string_utf8(opts, global_object, b"accessKeyId", true)? { new_credentials.credentials.access_key_id = Box::<[u8]>::from(utf8.slice()); new_credentials._access_key_id_slice = Some(utf8); @@ -290,6 +299,6 @@ pub(crate) fn get_credentials_with_options( Ok(new_credentials) } -fn contains_newline_or_cr(value: &[u8]) -> bool { +pub(crate) fn contains_newline_or_cr(value: &[u8]) -> bool { strings::index_of_any(value, b"\r\n").is_some() } diff --git a/src/runtime/webcore/s3/download_stream.rs b/src/runtime/webcore/s3/download_stream.rs index 9e4cc0c09469..35699dfcb731 100644 --- a/src/runtime/webcore/s3/download_stream.rs +++ b/src/runtime/webcore/s3/download_stream.rs @@ -347,10 +347,10 @@ impl S3HttpDownloadStreamingTask { /// # Safety /// `this` is live (registered ⇒ not yet freed by `on_response`); JS thread. pub(crate) unsafe fn stop_for_vm_teardown(this: *mut Self) { - // SAFETY: fn contract; `http` is initialised before the task is registered. + // SAFETY: fn contract. By id: the HTTP thread rewrites `http` on progress. unsafe { (*this).signal_store.aborted.store(true, Ordering::Relaxed); - bun_http::http_thread().schedule_shutdown((*this).http.assume_init_ref()); + bun_http::http_thread().schedule_shutdown_by_id((*this).async_http_id); } } diff --git a/src/runtime/webcore/s3/error_jsc.rs b/src/runtime/webcore/s3/error_jsc.rs index 10ef1ddb6ac0..5f89857b75eb 100644 --- a/src/runtime/webcore/s3/error_jsc.rs +++ b/src/runtime/webcore/s3/error_jsc.rs @@ -215,3 +215,47 @@ impl S3ErrorJsc for S3Error<'_> { )) } } + +/// For synchronous entry points (`presign`): if the credentials come from +/// the ambient chain and nothing is cached, take what the chain yields +/// without waiting (environment / static profile keys); if it needs network +/// I/O, that now runs in the background and this throws, naming the way out. +pub(crate) fn resolve_ambient_credentials_or_throw( + credentials: &bun_s3_signing::S3Credentials, + global: &JSGlobalObject, + path: Option<&[u8]>, +) -> bun_jsc::JsResult<()> { + if !credentials.needs_credentials_resolution() { + return Ok(()); + } + let Some(provider) = &credentials.provider else { + return Ok(()); + }; + let Some(default) = crate::webcore::aws::provider::as_default(provider) else { + return Ok(()); + }; + use crate::webcore::cloud::flight::{Now, resolve_now_or_start}; + let (code, message): (&[u8], Vec) = match resolve_now_or_start(global, &default) { + Now::Ready(Ok(_)) => return Ok(()), + Now::Ready(Err(err)) => (err.s3_code().as_bytes(), err.message.to_vec()), + Now::Pending { previous: None } => { + (b"ERR_S3_MISSING_CREDENTIALS", default.pending_message()) + } + Now::Pending { + previous: Some(err), + } => { + let mut message = default.pending_message(); + message.extend_from_slice(b". The previous attempt failed: "); + message.extend_from_slice(&err.message); + (err.s3_code().as_bytes(), message) + } + }; + Err(global.throw_value(s3_error_to_js( + &S3Error { + code, + message: &message, + }, + global, + path, + ))) +} diff --git a/src/runtime/webcore/s3/simple_request.rs b/src/runtime/webcore/s3/simple_request.rs index a05b3e529091..425b73d2726b 100644 --- a/src/runtime/webcore/s3/simple_request.rs +++ b/src/runtime/webcore/s3/simple_request.rs @@ -1,6 +1,7 @@ use core::ffi::c_void; use core::sync::atomic::Ordering; +use crate::timer::CallbackTimer; use bun_core::MutableString; use bun_event_loop::ConcurrentTask::{AutoDeinit, ConcurrentTask}; use bun_event_loop::{TaskTag, Taskable, task_tag}; @@ -120,8 +121,7 @@ pub struct S3HttpSimpleTask { pub(crate) http_ticket: Option, pub(crate) sign_result: SignResult, pub(crate) headers: Headers, - pub(crate) callback_context: *mut c_void, - pub callback: Callback, + pub(crate) completion: Completion, pub(crate) response_buffer: MutableString, pub(crate) result: HTTPClientResult<'static>, pub(crate) concurrent_task: ConcurrentTask, @@ -137,6 +137,11 @@ pub struct S3HttpSimpleTask { /// The HTTP client's abort flag: set by the VM's stop phase so a request /// still queued or in flight fails promptly and comes back. pub(crate) signal_store: bun_http::signals::Store, + /// Raw requests' wall-clock deadline (uSockets idle timeouts are too + /// coarse for a 1s metadata probe): aborts the request when it fires. + pub(crate) deadline: Option>, + pub(crate) deadline_hit: bool, + pub(crate) async_http_id: u32, } impl Taskable for S3HttpSimpleTask { @@ -149,6 +154,7 @@ impl Taskable for S3HttpSimpleTask { } } +#[derive(Clone, Copy)] pub enum Callback { Stat(fn(S3StatResult<'_>, *mut c_void) -> bun_jsc::JsResult<()>), Download(fn(S3DownloadResult<'_>, *mut c_void) -> bun_jsc::JsResult<()>), @@ -159,8 +165,98 @@ pub enum Callback { Part(fn(S3PartResult<'_>, *mut c_void) -> bun_jsc::JsResult<()>), } +/// Who gets the outcome: an S3 result decoder, or (credential endpoints) +/// the raw response whatever its status. +pub(crate) enum Completion { + S3 { + callback: Callback, + context: *mut c_void, + }, + Raw(Option), +} + +/// A request that is sent as-is (no S3 signing) via [`execute_raw_request`]. +pub struct RawRequest { + pub method: Method, + pub url: Box<[u8]>, + pub headers: Vec<(Box<[u8]>, Box<[u8]>)>, + pub body: Box<[u8]>, + /// Already filtered for `NO_PROXY`. + pub proxy_url: Option>, + /// Whole-request deadline. + pub timeout_ms: u32, + /// Whether the request in flight keeps the process from exiting. + pub holds_event_loop: bool, +} + +impl RawRequest { + pub fn new(method: Method, url: Vec) -> Self { + Self { + method, + url: url.into_boxed_slice(), + headers: Vec::new(), + body: Box::default(), + proxy_url: None, + timeout_ms: 30_000, + holds_event_loop: true, + } + } + + pub fn get(url: Vec) -> Self { + Self::new(Method::GET, url) + } + + pub fn post(url: Vec, body: Vec) -> Self { + Self { + body: body.into_boxed_slice(), + ..Self::new(Method::POST, url) + } + } + + pub fn header(mut self, name: &[u8], value: &[u8]) -> Self { + self.headers.push((Box::from(name), Box::from(value))); + self + } + + pub fn timeout(mut self, ms: u32) -> Self { + self.timeout_ms = ms; + self + } +} + +pub struct RawResponse { + pub status: u32, + pub body: Vec, + metadata: bun_http::HTTPResponseMetadata, +} + +impl RawResponse { + pub fn header(&self, name: &[u8]) -> Option<&[u8]> { + self.metadata.response.headers.get(name) + } +} + +/// `Err(None)`: the connection ended without a response or an error. +pub(crate) type RawCallback = + Box>) -> bun_jsc::JsResult<()>>; + impl Callback { - fn fail(&self, code: &[u8], message: &[u8], context: *mut c_void) -> bun_jsc::JsResult<()> { + /// A credential-provider failure, surfaced with the S3 error code + /// existing callers match on when nothing was found at all. + pub(crate) fn fail_credentials( + &self, + err: &bun_s3_signing::ProviderError, + context: *mut c_void, + ) -> bun_jsc::JsResult<()> { + self.fail(err.s3_code().as_bytes(), &err.message, context) + } + + pub(crate) fn fail( + &self, + code: &[u8], + message: &[u8], + context: *mut c_void, + ) -> bun_jsc::JsResult<()> { let err = S3Error { code, message }; match self { Callback::Upload(callback) => callback(S3UploadResult::Failure(err), context)?, @@ -207,12 +303,46 @@ enum ErrorType { impl S3HttpSimpleTask { const HOLDS_TICKET: &str = "S3 request on the HTTP thread holds a ticket"; - // bun.TrivialNew(@This()) — heap-allocate; pointer crosses thread boundary via http callback - pub(crate) fn new(init: Self) -> *mut Self { - bun_core::heap::into_raw(Box::new(init)) + /// Heap-allocate a task (the pointer crosses to the HTTP thread and + /// back) holding a ref on the event loop; [`send`] puts it on the wire. + pub(crate) fn create( + sign_result: SignResult, + completion: Completion, + headers: Headers, + proxy_url: Option<&[u8]>, + body: Box<[u8]>, + ) -> *mut Self { + let mut poll_ref = KeepAlive::init(); + poll_ref.ref_(bun_io::js_vm_ctx()); + bun_core::heap::into_raw(Box::new(S3HttpSimpleTask { + // Written in `send` via `MaybeUninit::write` before any read. + http: core::mem::MaybeUninit::uninit(), + http_ticket: None, + sign_result, + headers, + completion, + response_buffer: MutableString::default(), + result: HTTPClientResult::default(), + concurrent_task: ConcurrentTask::default(), + proxy_url: proxy_url + .filter(|p| !p.is_empty()) + .map(Box::from) + .unwrap_or_default(), + body, + poll_ref, + signal_store: Default::default(), + deadline: None, + deadline_hit: false, + async_http_id: 0, + })) } - fn error_with_body(&self, error_type: ErrorType) -> bun_jsc::JsResult<()> { + fn error_with_body( + &self, + callback: Callback, + context: *mut c_void, + error_type: ErrorType, + ) -> bun_jsc::JsResult<()> { let mut code: &[u8] = b"UnknownError"; let mut message: &[u8] = b"an unexpected error has occurred"; let mut has_error_code = false; @@ -242,16 +372,20 @@ impl S3HttpSimpleTask { code = b"NoSuchKey"; message = b"The specified key does not exist."; } - self.callback - .not_found(code, message, self.callback_context)?; + callback.not_found(code, message, context)?; } else { - self.callback.fail(code, message, self.callback_context)?; + callback.fail(code, message, context)?; } Ok(()) } /// A commit can answer 200 and still carry an `` document. - fn fail_if_contains_error(&mut self, status: u32) -> bun_jsc::JsResult { + fn fail_if_contains_error( + &self, + callback: Callback, + context: *mut c_void, + status: u32, + ) -> bun_jsc::JsResult { let mut code: &[u8] = b"UnknownError"; let mut message: &[u8] = b"an unexpected error has occurred"; let parsed; @@ -271,7 +405,7 @@ impl S3HttpSimpleTask { return Ok(false); } } - self.callback.fail(code, message, self.callback_context)?; + callback.fail(code, message, context)?; Ok(true) } @@ -291,18 +425,38 @@ impl S3HttpSimpleTask { // reclaimed here exactly once via the ConcurrentTask `AutoDeinit::ManualDeinit` contract; // `this` is dropped at scope exit. let mut this = unsafe { bun_core::heap::take(this) }; + drop(this.deadline.take()); + + let (callback, context) = match &mut this.completion { + Completion::Raw(on_done) => { + let on_done = on_done.take().expect("raw completion runs once"); + let result = match (this.result.fail, this.result.metadata.take()) { + (Some(_), _) if this.deadline_hit && this.result.is_abort() => { + Err(Some(bun_http::Error::Timeout)) + } + (Some(err), _) => Err(Some(err)), + (None, None) => Err(None), + (None, Some(metadata)) => Ok(RawResponse { + status: metadata.response.status_code, + body: core::mem::take(&mut this.response_buffer.list), + metadata, + }), + }; + return on_done(result); + } + Completion::S3 { callback, context } => (*callback, *context), + }; if !this.result.is_success() { - this.error_with_body(ErrorType::Failure)?; + this.error_with_body(callback, context, ErrorType::Failure)?; return Ok(()); } debug_assert!(this.result.metadata.is_some()); - // reshaped for borrowck — borrow response once, dispatch on a copy of `callback`. let response = &this.result.metadata.as_ref().unwrap().response; - match this.callback { - Callback::Stat(callback) => match response.status_code { + match callback { + Callback::Stat(cb) => match response.status_code { 200 => { - callback( + cb( S3StatResult::Success(S3StatSuccess { etag: response.headers.get(b"etag").unwrap_or(b""), last_modified: response.headers.get(b"last-modified").unwrap_or(b""), @@ -313,18 +467,18 @@ impl S3HttpSimpleTask { .map(bun_http_types::parse_content_length) .unwrap_or(0), }), - this.callback_context, + context, )?; } - 404 => this.error_with_body(ErrorType::NotFound)?, - _ => this.error_with_body(ErrorType::Failure)?, + 404 => this.error_with_body(callback, context, ErrorType::NotFound)?, + _ => this.error_with_body(callback, context, ErrorType::Failure)?, }, - Callback::Delete(callback) => match response.status_code { - 200 | 204 => callback(S3DeleteResult::Success, this.callback_context)?, - 404 => this.error_with_body(ErrorType::NotFound)?, - _ => this.error_with_body(ErrorType::Failure)?, + Callback::Delete(cb) => match response.status_code { + 200 | 204 => cb(S3DeleteResult::Success, context)?, + 404 => this.error_with_body(callback, context, ErrorType::NotFound)?, + _ => this.error_with_body(callback, context, ErrorType::Failure)?, }, - Callback::ListObjects(callback) => match response.status_code { + Callback::ListObjects(cb) => match response.status_code { 200 => { let body = this.response_buffer.list.as_slice(); let result = match list_objects::parse_s3_list_objects_result(body) { @@ -337,44 +491,41 @@ impl S3HttpSimpleTask { message: b"ListObjectsV2 response is not a well-formed document (if keys can contain control characters, pass encodingType: \"url\")", }), }; - callback(result, this.callback_context)?; + cb(result, context)?; } - 404 => this.error_with_body(ErrorType::NotFound)?, - _ => this.error_with_body(ErrorType::Failure)?, + 404 => this.error_with_body(callback, context, ErrorType::NotFound)?, + _ => this.error_with_body(callback, context, ErrorType::Failure)?, }, - Callback::Upload(callback) => match response.status_code { - 200 => callback(S3UploadResult::Success, this.callback_context)?, - _ => this.error_with_body(ErrorType::Failure)?, + Callback::Upload(cb) => match response.status_code { + 200 => cb(S3UploadResult::Success, context)?, + _ => this.error_with_body(callback, context, ErrorType::Failure)?, }, - Callback::Download(callback) => match response.status_code { + Callback::Download(cb) => match response.status_code { 200 | 204 | 206 => { let body = core::mem::take(&mut this.response_buffer); - callback( + cb( S3DownloadResult::Success(S3DownloadSuccess { body }), - this.callback_context, + context, )?; } - 404 => this.error_with_body(ErrorType::NotFound)?, - _ => { - // error - this.error_with_body(ErrorType::Failure)?; - } + 404 => this.error_with_body(callback, context, ErrorType::NotFound)?, + _ => this.error_with_body(callback, context, ErrorType::Failure)?, }, - Callback::Commit(callback) => { + Callback::Commit(cb) => { // commit multipart upload can fail with status 200 let status = response.status_code; - if !this.fail_if_contains_error(status)? { - callback(S3CommitResult::Success, this.callback_context)?; + if !this.fail_if_contains_error(callback, context, status)? { + cb(S3CommitResult::Success, context)?; } } - Callback::Part(callback) => { + Callback::Part(cb) => { let status = response.status_code; - if !this.fail_if_contains_error(status)? { + if !this.fail_if_contains_error(callback, context, status)? { let response = &this.result.metadata.as_ref().unwrap().response; if let Some(etag) = response.headers.get(b"etag") { - callback(S3PartResult::Etag(etag), this.callback_context)?; + cb(S3PartResult::Etag(etag), context)?; } else { - this.error_with_body(ErrorType::Failure)?; + this.error_with_body(callback, context, ErrorType::Failure)?; } } } @@ -468,10 +619,26 @@ impl S3HttpSimpleTask { /// # Safety /// `this` is live (registered ⇒ its response has not run); JS thread. pub(crate) unsafe fn stop_for_vm_teardown(this: *mut Self) { - // SAFETY: fn contract; `http` is initialised before the task is registered. + // SAFETY: fn contract. `async_http_id` is set before the task is + // registered and never changes, unlike `http`, which the HTTP thread + // rewrites on progress. unsafe { (*this).signal_store.aborted.store(true, Ordering::Relaxed); - bun_http::http_thread().schedule_shutdown((*this).http.assume_init_ref()); + bun_http::http_thread().schedule_shutdown_by_id((*this).async_http_id); + } + } + + /// The raw request's deadline passed (JS thread): abort it; the HTTP + /// thread hands it back and `on_response` reports a timeout. + fn on_deadline(task: usize) { + let this = task as *mut Self; + // SAFETY: `task` is the live task that owns the timer (dropped, and + // with it the timer, only in `on_response`); JS thread. + unsafe { + if !(*this).signal_store.aborted.load(Ordering::Relaxed) { + (*this).deadline_hit = true; + Self::stop_for_vm_teardown(this); + } } } @@ -530,6 +697,59 @@ pub struct S3SimpleRequestOptions<'a> { pub(crate) request_payer: bool, } +/// Owned copy of [`S3SimpleRequestOptions`] held while credentials resolve. +struct OwnedRequestOptions { + path: Box<[u8]>, + method: Method, + search_params: Option>, + content_type: Option>, + content_disposition: Option>, + content_encoding: Option>, + body: Box<[u8]>, + proxy_url: Option>, + range: Option>, + acl: Option, + storage_class: Option, + request_payer: bool, +} + +impl OwnedRequestOptions { + fn from(o: S3SimpleRequestOptions<'_>) -> Self { + let own = |s: Option<&[u8]>| s.map(Box::<[u8]>::from); + Self { + path: Box::from(o.path), + method: o.method, + search_params: own(o.search_params), + content_type: own(o.content_type), + content_disposition: own(o.content_disposition), + content_encoding: own(o.content_encoding), + body: Box::from(o.body), + proxy_url: own(o.proxy_url), + range: o.range, + acl: o.acl, + storage_class: o.storage_class, + request_payer: o.request_payer, + } + } + + fn borrow(&mut self) -> S3SimpleRequestOptions<'_> { + S3SimpleRequestOptions { + path: &self.path, + method: self.method, + search_params: self.search_params.as_deref(), + content_type: self.content_type.as_deref(), + content_disposition: self.content_disposition.as_deref(), + content_encoding: self.content_encoding.as_deref(), + body: &self.body, + proxy_url: self.proxy_url.as_deref(), + range: self.range.take(), + acl: self.acl, + storage_class: self.storage_class, + request_payer: self.request_payer, + } + } +} + impl<'a> Default for S3SimpleRequestOptions<'a> { fn default() -> Self { Self { @@ -557,6 +777,37 @@ pub(crate) fn execute_simple_s3_request( ) -> bun_jsc::JsResult<()> { // A multipart/retry continuation can reach here from teardown's queue // release; nothing new leaves a VM that is stopping. + if !VirtualMachine::get().script_allowed() { + drop(options.range); + callback.fail( + b"ERR_S3_VM_SHUTDOWN", + b"The JavaScript VM that owns this request is shutting down", + callback_context, + )?; + return Ok(()); + } + if this.needs_credentials_resolution() { + let provider = this.provider.clone().expect("needs_credentials_resolution"); + let credentials = this.clone(); + let mut owned = OwnedRequestOptions::from(options); + return crate::webcore::aws::resolve_shared_async( + VirtualMachine::get().global(), + &provider, + Box::new(move |result| match result { + Err(err) => callback.fail_credentials(&err, callback_context), + Ok(_) => sign_and_send(&credentials, owned.borrow(), callback, callback_context), + }), + ); + } + sign_and_send(this, options, callback, callback_context) +} + +fn sign_and_send( + this: &S3Credentials, + options: S3SimpleRequestOptions<'_>, + callback: Callback, + callback_context: *mut c_void, +) -> bun_jsc::JsResult<()> { if !VirtualMachine::get().script_allowed() { drop(options.range); callback.fail( @@ -616,31 +867,79 @@ pub(crate) fn execute_simple_s3_request( } }; - let mut poll_ref = KeepAlive::init(); - poll_ref.ref_(bun_io::posix_event_loop::get_vm_ctx( - bun_io::AllocatorType::Js, - )); - let proxy = options.proxy_url.unwrap_or(b""); - let task_ptr = S3HttpSimpleTask::new(S3HttpSimpleTask { - // written below via `MaybeUninit::write` before any read. - http: core::mem::MaybeUninit::uninit(), - sign_result: result, - callback_context, - callback, + let task_ptr = S3HttpSimpleTask::create( + result, + Completion::S3 { + callback, + context: callback_context, + }, headers, - http_ticket: None, - response_buffer: MutableString::default(), - result: HTTPClientResult::default(), - concurrent_task: ConcurrentTask::default(), - proxy_url: if !proxy.is_empty() { - Box::<[u8]>::from(proxy) - } else { - Box::default() + options.proxy_url, + Box::<[u8]>::from(options.body), + ); + send( + task_ptr, + options.method, + FetchRedirect::Follow, + HttpOptions::default(), + ); + Ok(()) +} + +/// Send `request` on the HTTP thread and call `on_done` on this JS thread +/// with whatever comes back. Aborted at VM teardown like any S3 request. +pub(crate) fn execute_raw_request(request: RawRequest, on_done: RawCallback) { + if !VirtualMachine::get().script_allowed() { + let _ = on_done(Err(Some(bun_http::Error::Aborted))); + return; + } + let pico: Vec = request + .headers + .iter() + .map(|(k, v)| picohttp::Header::new(k, v)) + .collect(); + let headers = Headers::from_pico_http_headers(&pico); + let mut sign_result = SignResult::default(); + sign_result.url = request.url; + let task_ptr = S3HttpSimpleTask::create( + sign_result, + Completion::Raw(Some(on_done)), + headers, + request.proxy_url.as_deref(), + request.body, + ); + let mut timer = CallbackTimer::new(S3HttpSimpleTask::on_deadline, task_ptr as usize); + timer.schedule(u64::from(request.timeout_ms)); + // SAFETY: freshly allocated, not yet shared with the HTTP thread. + unsafe { + (*task_ptr).deadline = Some(timer); + if !request.holds_event_loop { + (*task_ptr).poll_ref.unref(bun_io::js_vm_ctx()); + } + } + send( + task_ptr, + request.method, + FetchRedirect::Manual, + HttpOptions { + disable_keepalive: Some(true), + // Credential exchanges carry bearer tokens; keep them out of + // BUN_CONFIG_VERBOSE_FETCH output. + verbose: Some(bun_http::HTTPVerboseLevel::None), + ..Default::default() }, - body: Box::<[u8]>::from(options.body), - poll_ref, - signal_store: Default::default(), - }); + ); +} + +/// Hand a task from [`S3HttpSimpleTask::create`] to the HTTP thread. +/// `options.http_proxy`, `signals` and `reject_unauthorized` are filled in +/// here; `verbose` unless set. +pub(crate) fn send( + task_ptr: *mut S3HttpSimpleTask, + method: Method, + redirect: FetchRedirect, + options: HttpOptions<'static>, +) { // SAFETY: `task_ptr` is a freshly heap-allocated pointer; shared reads only until // the scoped exclusive `http` writes below. let task = unsafe { &*task_ptr }; @@ -667,10 +966,10 @@ pub(crate) fn execute_simple_s3_request( None }; let vm = VirtualMachine::get(); - let verbose = vm.get_verbose_fetch(); + let verbose = options.verbose.unwrap_or_else(|| vm.get_verbose_fetch()); let reject_unauthorized = vm.get_tls_reject_unauthorized(); let async_http = AsyncHTTP::init( - options.method, + method, url, task.headers.entries.clone().expect("OOM"), headers_buf, @@ -682,7 +981,7 @@ pub(crate) fn execute_simple_s3_request( S3HttpSimpleTask::http_callback, S3HttpSimpleTask::release_at_shutdown, ), - FetchRedirect::Follow, + redirect, HttpOptions { http_proxy, verbose: Some(verbose), @@ -690,12 +989,15 @@ pub(crate) fn execute_simple_s3_request( // SAFETY: `task_ptr` outlives the request; the store is only read // through these pointers by the HTTP client. signals: Some(unsafe { (*task_ptr).signal_store.to() }), - ..Default::default() + ..options }, ); // SAFETY: `task_ptr` is still the sole pointer (the HTTP thread only sees it after // `schedule` below); scoped exclusive write of the `http` field. - unsafe { (*task_ptr).http.write(async_http) }; + unsafe { + (*task_ptr).async_http_id = async_http.async_http_id; + (*task_ptr).http.write(async_http); + } // queue http request bun_http::http_thread::init(&Default::default()); let mut batch = thread_pool::Batch::default(); @@ -708,5 +1010,4 @@ pub(crate) fn execute_simple_s3_request( crate::jsc_hooks::ActiveHandle::S3Request(core::ptr::NonNull::new(task_ptr).expect("task")) .register(); bun_http::HTTPThread::schedule(batch); - Ok(()) } diff --git a/src/s3_signing/aws_credentials.rs b/src/s3_signing/aws_credentials.rs new file mode 100644 index 000000000000..2041eedf0a88 --- /dev/null +++ b/src/s3_signing/aws_credentials.rs @@ -0,0 +1,133 @@ +//! Resolved AWS credentials and the provider hook `S3Credentials` falls back +//! to when it has no static keys. The provider *implementation* (the default +//! chain: env → profile/SSO/process/web-identity → container → IMDS) lives in +//! `bun_runtime::webcore::aws`; this crate only names the interface. + +use std::sync::Arc; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum CredentialsSource { + Explicit, + Env, + Profile, + AssumeRole, + WebIdentity, + Process, + Sso, + Container, + Imds, +} + +impl CredentialsSource { + pub const fn as_str(self) -> &'static str { + match self { + Self::Explicit => "explicit", + Self::Env => "env", + Self::Profile => "profile", + Self::AssumeRole => "assume-role", + Self::WebIdentity => "web-identity", + Self::Process => "process", + Self::Sso => "sso", + Self::Container => "container", + Self::Imds => "imds", + } + } +} + +pub struct AwsCredentials { + pub access_key_id: Box<[u8]>, + pub secret_access_key: Box<[u8]>, + /// Empty when the credentials are long-lived. + pub session_token: Box<[u8]>, + /// Unix epoch seconds; `None` for non-expiring credentials. + pub expiration: Option, + pub account_id: Option>, + /// Region configured alongside the credentials (profile `region`), used + /// when the caller did not set one. + pub region: Option>, + pub source: CredentialsSource, +} + +impl AwsCredentials { + /// Credentials are refreshed this long before they expire. + pub const REFRESH_WINDOW_SECONDS: u64 = 300; + /// Credentials this close to expiry count as expired. + pub const EXPIRY_MARGIN_SECONDS: u64 = 5; + + pub fn session_token(&self) -> Option<&[u8]> { + if self.session_token.is_empty() { + None + } else { + Some(&self.session_token) + } + } + + pub fn sigv4(&self) -> crate::sigv4::Credentials<'_> { + crate::sigv4::Credentials { + access_key_id: &self.access_key_id, + secret_access_key: &self.secret_access_key, + session_token: self.session_token(), + } + } +} + +impl Drop for AwsCredentials { + fn drop(&mut self) { + bun_core::secure_zero_slice(&mut self.secret_access_key); + bun_core::secure_zero_slice(&mut self.session_token); + } +} + +impl core::fmt::Debug for AwsCredentials { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("AwsCredentials") + .field("access_key_id", &bstr::BStr::new(&self.access_key_id)) + .field("source", &self.source) + .field("expiration", &self.expiration) + .finish_non_exhaustive() + } +} + +/// Why credential resolution failed. `message` is user-facing. +#[derive(Debug)] +pub struct ProviderError { + pub code: &'static str, + pub message: Box<[u8]>, +} + +impl ProviderError { + pub fn new(code: &'static str, message: impl Into>) -> Self { + Self { + code, + message: message.into().into_boxed_slice(), + } + } + + /// The code S3 APIs report: they have always said + /// `ERR_S3_MISSING_CREDENTIALS` when there was nothing to sign with. + pub fn s3_code(&self) -> &'static str { + if self.code == "ERR_AWS_MISSING_CREDENTIALS" { + "ERR_S3_MISSING_CREDENTIALS" + } else { + self.code + } + } +} + +/// A source of credentials that may need I/O to produce them. +pub trait CredentialsProvider: Send + Sync { + /// Non-blocking: cached credentials that have not expired (they may be + /// inside the refresh window). + fn cached(&self) -> Option>; + + /// Nothing usable is cached: callers resolve (asynchronously) before + /// signing. Implementations may use this as the cue to refresh + /// soon-to-expire credentials in the background. + fn needs_resolution(&self) -> bool; + + /// A stable label for `console.log` / errors (e.g. `default`, a profile + /// name, or `function`). + fn label(&self) -> &[u8]; +} + +pub type SharedProvider = Arc; diff --git a/src/s3_signing/crate_error.rs b/src/s3_signing/crate_error.rs index 5e217cc9568c..335fae30bcd7 100644 --- a/src/s3_signing/crate_error.rs +++ b/src/s3_signing/crate_error.rs @@ -45,7 +45,8 @@ impl From for Error { SignError::InvalidSessionToken => Self::InvalidSessionToken, SignError::InvalidHeaderValue | SignError::FailedToGenerateSignature - | SignError::NoSpaceLeft => Self::SignError, + | SignError::NoSpaceLeft + | SignError::InvalidExpires => Self::SignError, } } } diff --git a/src/s3_signing/credentials.rs b/src/s3_signing/credentials.rs index 3996162fe2b4..ad40c48dc9f2 100644 --- a/src/s3_signing/credentials.rs +++ b/src/s3_signing/credentials.rs @@ -9,6 +9,7 @@ use bun_picohttp::Header as PicoHeader; use bun_ptr::{IntrusiveRc, RawSlice, RefCount}; use super::acl::ACL; +use super::aws_credentials::{AwsCredentials, SharedProvider}; use super::storage_class::StorageClass; bun_core::declare_scope!(AWS, visible); @@ -181,6 +182,8 @@ pub struct S3Credentials { pub insecure_http: bool, /// indicates if the endpoint is a virtual hosted style bucket pub virtual_hosted_style: bool, + /// Consulted when `access_key_id`/`secret_access_key` are empty. + pub provider: Option, } // `S3Credentials` owns its bytes via @@ -200,6 +203,7 @@ impl Clone for S3Credentials { storage_class: self.storage_class, insecure_http: self.insecure_http, virtual_hosted_style: self.virtual_hosted_style, + provider: self.provider.clone(), } } } @@ -217,6 +221,7 @@ impl Default for S3Credentials { storage_class: None, insecure_http: false, virtual_hosted_style: false, + provider: None, } } } @@ -247,9 +252,28 @@ impl S3Credentials { storage_class: None, insecure_http, virtual_hosted_style: false, + provider: None, } } + pub fn has_static_credentials(&self) -> bool { + !self.access_key_id.is_empty() && !self.secret_access_key.is_empty() + } + + /// Ambient credentials apply only when *neither* key was given; one + /// without the other is a configuration error, not a cue to go looking. + pub fn uses_provider(&self) -> bool { + self.access_key_id.is_empty() + && self.secret_access_key.is_empty() + && self.provider.is_some() + } + + /// True when signing would have to wait on the provider (nothing static, + /// nothing cached). Asynchronous callers resolve first in that case. + pub fn needs_credentials_resolution(&self) -> bool { + self.uses_provider() && self.provider.as_ref().is_some_and(|p| p.needs_resolution()) + } + pub fn estimated_size(&self) -> usize { size_of::() + self.access_key_id.len() @@ -271,6 +295,7 @@ impl S3Credentials { storage_class: None, insecure_http: self.insecure_http, virtual_hosted_style: self.virtual_hosted_style, + provider: self.provider.clone(), }) } @@ -306,19 +331,33 @@ impl S3Credentials { if matches!(content_encoding, Some(s) if s.is_empty()) { content_encoding = None; } - let session_token: Option<&[u8]> = if self.session_token.is_empty() { - None - } else { - Some(&self.session_token) - }; - let acl: Option<&'static [u8]> = sign_options.acl.map(|a| a.to_string()); let storage_class: Option<&'static [u8]> = sign_options.storage_class.map(|s| s.to_string()); - if self.access_key_id.is_empty() || self.secret_access_key.is_empty() { - return Err(SignError::MissingCredentials); - } + let resolved: Option>; + let (access_key_id, secret_access_key, session_token): (&[u8], &[u8], Option<&[u8]>) = + if self.has_static_credentials() { + resolved = None; + ( + &self.access_key_id, + &self.secret_access_key, + if self.session_token.is_empty() { + None + } else { + Some(&self.session_token) + }, + ) + } else if self.uses_provider() + && let Some(provider) = &self.provider + { + resolved = Some(provider.cached().ok_or(SignError::MissingCredentials)?); + let r = resolved.as_deref().unwrap(); + (&r.access_key_id, &r.secret_access_key, r.session_token()) + } else { + return Err(SignError::MissingCredentials); + }; + let _ = &resolved; let sign_query = sign_query_option.is_some(); let expires = sign_query_option.map(|o| o.expires).unwrap_or(0); let method_name: &'static str = match method { @@ -332,6 +371,10 @@ impl S3Credentials { let region: &[u8] = if !self.region.is_empty() { &self.region + } else if let Some(r) = resolved.as_deref().and_then(|r| r.region.as_deref()) + && self.endpoint.is_empty() + { + r } else { guess_region(&self.endpoint) }; @@ -491,7 +534,7 @@ impl S3Credentials { hasher.update(b"\0"); hasher.update(service_name.as_bytes()); hasher.update(b"\0"); - hasher.update(&self.secret_access_key); + hasher.update(secret_access_key); hasher.r#final(&mut cache_key); } // was `bun_jsc::VirtualMachine::get*().rare_data().aws_cache()`. @@ -502,7 +545,7 @@ impl S3Credentials { // not cached yet lets generate a new one let aws4_key = buf_print( &mut tmp_buffer, - format_args!("AWS4{}", BStr::new(&self.secret_access_key)), + format_args!("AWS4{}", BStr::new(secret_access_key)), ) .map_err(|_| SignError::NoSpaceLeft)?; let sig_date = bun_sha_hmac::generate( @@ -599,7 +642,7 @@ impl S3Credentials { query_parts.push(alloc_print!("X-Amz-Algorithm=AWS4-HMAC-SHA256")); query_parts.push(alloc_print!( "X-Amz-Credential={}%2F{}%2F{}%2F{}%2Faws4_request", - BStr::new(&self.access_key_id), + BStr::new(access_key_id), BStr::new(amz_day), BStr::new(region), service_name @@ -689,7 +732,7 @@ impl S3Credentials { url_query_parts.push(alloc_print!("X-Amz-Algorithm=AWS4-HMAC-SHA256")); url_query_parts.push(alloc_print!( "X-Amz-Credential={}%2F{}%2F{}%2F{}%2Faws4_request", - BStr::new(&self.access_key_id), + BStr::new(access_key_id), BStr::new(amz_day), BStr::new(region), service_name @@ -785,7 +828,7 @@ impl S3Credentials { break 'brk alloc_print!( "AWS4-HMAC-SHA256 Credential={}/{}/{}/{}/aws4_request, SignedHeaders={}, Signature={}", - BStr::new(&self.access_key_id), + BStr::new(access_key_id), BStr::new(amz_day), BStr::new(region), service_name, @@ -817,7 +860,7 @@ impl S3Credentials { || content_encoding.is_some_and(contains_newline_or_cr) || session_token.is_some_and(contains_newline_or_cr) || contains_newline_or_cr(region) - || contains_newline_or_cr(&self.access_key_id) + || contains_newline_or_cr(access_key_id) || contains_newline_or_cr(&host) { return Err(SignError::InvalidHeaderValue); @@ -943,7 +986,7 @@ fn get_amz_date() -> DateResult { // Gregorian Y/M/D from Unix-epoch seconds, using Howard Hinnant's // `civil_from_days` algorithm (public domain). -fn epoch_to_utc_components(secs: u64) -> (u32, u32, u32, u32, u32, u32, u64) { +pub(crate) fn epoch_to_utc_components(secs: u64) -> (u32, u32, u32, u32, u32, u32, u64) { // returns (year, month(1-based), day(1-based), hours, minutes, seconds, seconds_into_day) let day_seconds = secs % 86_400; let hours = u32::try_from(day_seconds / 3600).expect("int cast"); @@ -1223,6 +1266,8 @@ pub enum SignError { FailedToGenerateSignature, #[error("NoSpaceLeft")] NoSpaceLeft, + #[error("InvalidExpires")] + InvalidExpires, } impl<'a> Default for SignOptions<'a> { diff --git a/src/s3_signing/lib.rs b/src/s3_signing/lib.rs index 41dbe7b939c7..82db0b70c722 100644 --- a/src/s3_signing/lib.rs +++ b/src/s3_signing/lib.rs @@ -7,7 +7,13 @@ pub mod storage_class; pub use crate_error::Error; +pub mod aws_credentials; pub mod credentials; +pub mod sigv4; + +pub use aws_credentials::{ + AwsCredentials, CredentialsProvider, CredentialsSource, ProviderError, SharedProvider, +}; pub use acl::ACL; pub use credentials::*; diff --git a/src/s3_signing/sigv4.rs b/src/s3_signing/sigv4.rs new file mode 100644 index 000000000000..fe8e9be4729e --- /dev/null +++ b/src/s3_signing/sigv4.rs @@ -0,0 +1,1171 @@ +//! Generic AWS Signature Version 4 signer (header and query-string forms) for +//! any service. `credentials.rs` keeps the S3-specialised fast path; this is +//! what `fetch(url, { aws })`, `Bun.aws.sign/presign` and STS AssumeRole use. +//! +//! + +use std::io::Write as _; + +use bstr::BStr; +use bun_core::fmt::hex_lower; +use bun_core::strings; +use bun_sha_hmac::hmac::EVP_MAX_MD_SIZE; +use bun_sha_hmac::sha::hashers::SHA256; + +use crate::credentials::SignError; + +pub const UNSIGNED_PAYLOAD: &[u8] = b"UNSIGNED-PAYLOAD"; +pub const EMPTY_SHA256: &[u8] = b"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; +/// Presigned URLs are valid for at most seven days. +pub const MAX_PRESIGN_EXPIRES: u32 = 604_800; + +#[derive(Clone, Copy)] +pub struct Credentials<'a> { + pub access_key_id: &'a [u8], + pub secret_access_key: &'a [u8], + pub session_token: Option<&'a [u8]>, +} + +#[derive(Clone, Copy)] +pub enum Payload<'a> { + /// Hash these bytes. + Bytes(&'a [u8]), + /// `UNSIGNED-PAYLOAD` (S3-family services only). + Unsigned, + /// Caller already has the lowercase hex SHA-256. + Sha256Hex(&'a [u8]), +} + +#[derive(Clone, Copy)] +pub struct Scope<'a> { + pub service: &'a [u8], + pub region: &'a [u8], +} + +pub struct Request<'a> { + pub method: &'a [u8], + /// `Host` header value: hostname plus `:port` when non-default. + pub host: &'a [u8], + /// Path exactly as it will be sent (already percent-encoded), no query. + pub path: &'a [u8], + /// Raw query string without the leading `?`. + pub query: &'a [u8], + /// Headers to sign besides `host` / `x-amz-*` that the signer adds. Names + /// in any case; values as they will be sent. + pub headers: &'a [(&'a [u8], &'a [u8])], + pub payload: Payload<'a>, + pub scope: Scope<'a>, + /// `YYYYMMDDTHHMMSSZ`; `None` = now. + pub datetime: Option<[u8; 16]>, + /// S3 (and S3-compatible) paths are encoded once and not normalised; + /// every other service double-encodes. `None` picks by service name. + pub s3_path_semantics: Option, +} + +/// Headers to add to the request. +pub struct SignedRequest { + pub authorization: Box<[u8]>, + pub amz_date: [u8; 16], + /// Signed (and so must be sent) only when `send_content_sha256`. + pub content_sha256: Box<[u8]>, + pub send_content_sha256: bool, +} + +pub struct PresignedUrl { + pub url: Box<[u8]>, +} + +pub fn is_s3_service(service: &[u8]) -> bool { + matches!( + service, + b"s3" | b"s3-object-lambda" | b"s3-outposts" | b"s3express" + ) +} + +pub fn amz_datetime_now() -> [u8; 16] { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + amz_datetime(secs) +} + +pub fn amz_datetime(epoch_secs: u64) -> [u8; 16] { + let (year, month, day, hours, minutes, seconds, _) = + crate::credentials::epoch_to_utc_components(epoch_secs); + let mut out = [0u8; 16]; + let _ = bun_core::fmt::buf_print( + &mut out, + format_args!("{year:04}{month:02}{day:02}T{hours:02}{minutes:02}{seconds:02}Z"), + ); + out +} + +/// Parses `YYYYMMDDTHHMMSSZ` / ISO-8601 `YYYY-MM-DDTHH:MM:SS(.fff)Z` into +/// epoch seconds. Credential documents (IMDS, ECS, STS, SSO, +/// credential_process) all use the ISO form for `Expiration`. +pub fn parse_iso8601(s: &[u8]) -> Option { + let digits = |r: core::ops::Range| -> Option { + let part = s.get(r)?; + if part.iter().all(u8::is_ascii_digit) { + core::str::from_utf8(part).ok()?.parse().ok() + } else { + None + } + }; + let (y, mo, d, h, mi, se); + // Seconds east of UTC (subtracted at the end). + let mut offset: i64 = 0; + if s.len() == 16 && s[8] == b'T' && s[15] == b'Z' { + y = digits(0..4)?; + mo = digits(4..6)?; + d = digits(6..8)?; + h = digits(9..11)?; + mi = digits(11..13)?; + se = digits(13..15)?; + } else if s.len() >= 19 && s[4] == b'-' && (s[10] == b'T' || s[10] == b' ') { + y = digits(0..4)?; + mo = digits(5..7)?; + d = digits(8..10)?; + h = digits(11..13)?; + mi = digits(14..16)?; + se = digits(17..19)?; + // Optional fractional seconds, then `Z`, `+HH:MM` or `+HHMM`. + let mut rest = &s[19..]; + if let [b'.', tail @ ..] = rest { + let n = tail.iter().take_while(|b| b.is_ascii_digit()).count(); + rest = &tail[n..]; + } + offset = match rest { + [] | [b'Z'] | [b'z'] => 0, + [sign @ (b'+' | b'-'), tail @ ..] + if (tail.len() == 5 && tail[2] == b':') || tail.len() == 4 => + { + let two = |d: &[u8]| match d { + [a @ b'0'..=b'9', b @ b'0'..=b'9'] => { + Some(i64::from(a - b'0') * 10 + i64::from(b - b'0')) + } + _ => None, + }; + let (oh, om) = (two(&tail[..2])?, two(&tail[tail.len() - 2..])?); + if oh > 23 || om > 59 { + return None; + } + let secs = oh * 3600 + om * 60; + if *sign == b'+' { secs } else { -secs } + } + _ => return None, + }; + } else { + return None; + } + let leap = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0); + let days_in_month = match mo { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => return None, + }; + if !(1..=days_in_month).contains(&d) || h > 23 || mi > 59 || se > 60 { + return None; + } + let utc = days_from_civil(y, mo, d)? * 86_400 + h * 3600 + mi * 60 + se; + utc.checked_add_signed(-offset) +} + +// Howard Hinnant's `days_from_civil`, restricted to years >= 1970. +fn days_from_civil(y: u64, m: u64, d: u64) -> Option { + if y < 1970 { + return None; + } + let y = i64::try_from(y).ok()? - i64::from(m <= 2); + let era = y.div_euclid(400); + let yoe = (y - era * 400) as u64; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146_097 + i64::try_from(doe).ok()? - 719_468; + u64::try_from(days).ok() +} + +/// Best-effort `(service, region)` from an endpoint hostname. Either part is +/// `None` when it cannot be told from the name. `service` is the SigV4 +/// *signing name*, which for a few services differs from the hostname label. +pub fn infer_service_region(host: &[u8]) -> (Option>, Option>) { + let host = match strings::last_index_of_char(host, b':') { + Some(i) if !host.starts_with(b"[") || host[..i].ends_with(b"]") => &host[..i], + _ => host, + }; + let host = strings::trim(host, b"."); + let own = |s: &[u8]| Some(Box::<[u8]>::from(s)); + let labels_of = |s| -> Vec<&[u8]> { strings::split(s, b".").collect() }; + + if host.ends_with(b".r2.cloudflarestorage.com") { + return (own(b"s3"), own(b"auto")); + } + if let Some(stem) = host.strip_suffix(b".backblazeb2.com".as_slice()) { + // [.]s3..backblazeb2.com + let labels = labels_of(stem); + let region = match labels.as_slice() { + [.., service, region] if *service == b"s3" => own(region), + _ => None, + }; + return (own(b"s3"), region); + } + if let Some(stem) = host.strip_suffix(b".on.aws".as_slice()) { + // .lambda-url..on.aws + let labels = labels_of(stem); + let region = labels.last().copied().filter(|r| looks_like_region(r)); + if labels.len() >= 2 && labels[labels.len() - 2] == b"lambda-url" { + return (own(b"lambda"), region.and_then(own)); + } + return (None, region.and_then(own)); + } + let stem = if let Some(s) = host.strip_suffix(b".amazonaws.com".as_slice()) { + s + } else if let Some(s) = host.strip_suffix(b".amazonaws.com.cn".as_slice()) { + s + } else { + return (None, None); + }; + + // ..[.dualstack|.fips|.vpce].amazonaws.com + // ...amazonaws.com (es, aoss, older s3-website) + // .amazonaws.com (global: iam, sts, s3, cloudfront…) + let mut labels = labels_of(stem); + while labels.len() > 1 + && matches!( + labels.last().copied(), + Some(b"dualstack" | b"vpce" | b"fips" | b"api" | b"amazonaws") + ) + { + labels.pop(); + } + let Some(&last) = labels.last() else { + return (None, None); + }; + let is_modifier = |l: &[u8]| matches!(l, b"dualstack" | b"vpce" | b"fips"); + let (mut service, mut region): (&[u8], Option<&[u8]>) = if looks_like_region(last) { + labels.pop(); + while labels.len() > 1 && labels.last().is_some_and(|l| is_modifier(l)) { + labels.pop(); + } + match labels.pop() { + Some(svc) => (svc, Some(last)), + None => return (None, own(last)), + } + } else { + labels.pop(); + // `..` (rds, es, neptune, legacy + // `.queue` …): the label before the service is the region — + // except for S3, where it is a bucket that may merely look like one. + let r = if last == b"s3" || last.starts_with(b"s3-") { + None + } else { + labels.last().copied().filter(|l| looks_like_region(l)) + }; + if r.is_some() { + labels.clear(); + } + (last, r) + }; + let prefix = labels.last().copied(); + + if let Some(s) = service.strip_suffix(b"-fips".as_slice()) { + service = s; + } + // Legacy S3 spellings: s3-us-west-2, s3-external-1, s3-accelerate, + // s3-website-us-east-1, s3-control, s3-accesspoint. (But s3-outposts / + // s3-object-lambda are real signing names.) + if let Some(tail) = service.strip_prefix(b"s3-".as_slice()) { + let tail = tail.strip_prefix(b"fips-".as_slice()).unwrap_or(tail); + if looks_like_region(tail) { + region = region.or(Some(tail)); + service = b"s3"; + } else if let Some(r) = tail.strip_prefix(b"website-".as_slice()) { + if looks_like_region(r) { + region = region.or(Some(r)); + } + service = b"s3"; + } else if matches!( + tail, + b"accelerate" | b"control" | b"website" | b"accesspoint" + ) || tail.starts_with(b"external-") + { + service = b"s3"; + } + } + let service: &[u8] = match service { + b"email" => b"ses", + b"queue" => b"sqs", + b"bedrock-runtime" | b"bedrock-agent" | b"bedrock-agent-runtime" => b"bedrock", + b"iot" if prefix.is_some_and(|p| p.starts_with(b"data")) => b"iotdata", + b"appsync-api" | b"appsync-realtime-api" => b"appsync", + b"execute-api" => b"execute-api", + other => other, + }; + ( + own(service), + // No region label on an amazonaws.com host means a global endpoint, + // which signs as us-east-1. + Some(Box::from(region.unwrap_or(b"us-east-1"))), + ) +} + +fn looks_like_region(s: &[u8]) -> bool { + // us-east-1, eu-central-2, us-gov-west-1, cn-north-1, us-iso-east-1, us-isob-east-1 + if s.len() < 9 || !s[s.len() - 1].is_ascii_digit() || s[s.len() - 2] != b'-' { + return false; + } + let dashes = strings::count_char(s, b'-'); + (2..=3).contains(&dashes) + && s[..2].iter().all(u8::is_ascii_lowercase) + && s[2] == b'-' + && s.iter() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'-') +} + +// ─── canonicalisation ────────────────────────────────────────────────────── + +fn is_unreserved(c: u8) -> bool { + c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.' | b'~') +} + +fn push_pct(out: &mut Vec, c: u8) { + out.push(b'%'); + out.push(bun_core::fmt::hex_char_upper(c >> 4)); + out.push(bun_core::fmt::hex_char_upper(c & 0xF)); +} + +/// RFC 3986 encode; `/` kept when `keep_slash`. +pub fn uri_encode_into(out: &mut Vec, input: &[u8], keep_slash: bool) { + for &c in input { + if is_unreserved(c) || (keep_slash && c == b'/') { + out.push(c); + } else { + push_pct(out, c); + } + } +} + +fn hex_val(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + b'A'..=b'F' => Some(c - b'A' + 10), + _ => None, + } +} + +/// Decodes `%XX`; leaves malformed escapes and `+` as-is. +fn percent_decode(input: &[u8]) -> Vec { + let mut out = Vec::with_capacity(input.len()); + let mut i = 0; + while i < input.len() { + let c = input[i]; + if c == b'%' && i + 2 < input.len() { + if let (Some(h), Some(l)) = (hex_val(input[i + 1]), hex_val(input[i + 2])) { + out.push((h << 4) | l); + i += 3; + continue; + } + } + out.push(c); + i += 1; + } + out +} + +fn canonical_uri(out: &mut Vec, path: &[u8], s3: bool) { + if path.is_empty() { + out.push(b'/'); + return; + } + if s3 { + // Encode the *decoded* key once so `(`, `!`, spaces etc. match what S3 + // recomputes regardless of how the caller spelled them. + let decoded = percent_decode(path); + if !decoded.starts_with(b"/") { + out.push(b'/'); + } + uri_encode_into(out, &decoded, true); + return; + } + // Normalise `.`/`..`/`//` per RFC 3986 remove_dot_segments, then encode + // each (already once-encoded) segment again. + let mut segments: Vec<&[u8]> = Vec::new(); + for seg in strings::split(path, b"/") { + match seg { + b"" | b"." => {} + b".." => { + segments.pop(); + } + s => segments.push(s), + } + } + out.push(b'/'); + for (i, seg) in segments.iter().enumerate() { + if i > 0 { + out.push(b'/'); + } + uri_encode_into(out, seg, false); + } + if segments.is_empty() { + return; + } + if path.ends_with(b"/") { + out.push(b'/'); + } +} + +/// Sorted, re-encoded `name=value` pairs; a stale `X-Amz-Signature` is dropped. +fn canonical_query(out: &mut Vec, query: &[u8], extra: &[(Vec, Vec)]) { + let mut pairs: Vec<(Vec, Vec)> = Vec::new(); + for part in strings::split(query, b"&") { + if part.is_empty() { + continue; + } + let (k, v) = match strings::index_of_char_usize(part, b'=') { + Some(i) => (&part[..i], &part[i + 1..]), + None => (part, &b""[..]), + }; + if k == b"X-Amz-Signature" { + continue; + } + let mut ek = Vec::with_capacity(k.len()); + uri_encode_into(&mut ek, &percent_decode(k), false); + let mut ev = Vec::with_capacity(v.len()); + uri_encode_into(&mut ev, &percent_decode(v), false); + pairs.push((ek, ev)); + } + for (k, v) in extra { + pairs.push((k.clone(), v.clone())); + } + pairs.sort(); + for (i, (k, v)) in pairs.iter().enumerate() { + if i > 0 { + out.push(b'&'); + } + out.extend_from_slice(k); + out.push(b'='); + out.extend_from_slice(v); + } +} + +fn trim_and_collapse_ws(value: &[u8], out: &mut Vec) { + let v = strings::trim(value, b" \t"); + let mut prev_space = false; + for &c in v { + let is_space = c == b' ' || c == b'\t'; + if is_space { + if !prev_space { + out.push(b' '); + } + } else { + out.push(c); + } + prev_space = is_space; + } +} + +struct CanonicalHeaders { + /// `name:value\n…` + block: Vec, + /// `a;b;c` + names: Vec, +} + +fn canonical_headers( + host: &[u8], + amz_date: Option<&[u8]>, + content_sha256: Option<&[u8]>, + security_token: Option<&[u8]>, + user_headers: &[(&[u8], &[u8])], +) -> Result { + let mut entries: Vec<(Vec, Vec)> = Vec::with_capacity(user_headers.len() + 4); + entries.push((b"host".to_vec(), host.to_vec())); + if let Some(d) = amz_date { + entries.push((b"x-amz-date".to_vec(), d.to_vec())); + } + if let Some(h) = content_sha256 { + entries.push((b"x-amz-content-sha256".to_vec(), h.to_vec())); + } + if let Some(t) = security_token { + entries.push((b"x-amz-security-token".to_vec(), t.to_vec())); + } + for (name, value) in user_headers { + if name.is_empty() { + continue; + } + let lower: Vec = name.iter().map(u8::to_ascii_lowercase).collect(); + if matches!( + lower.as_slice(), + b"host" + | b"x-amz-date" + | b"x-amz-content-sha256" + | b"x-amz-security-token" + | b"authorization" + | b"connection" + | b"content-length" + | b"expect" + | b"keep-alive" + | b"proxy-authenticate" + | b"proxy-authorization" + | b"te" + | b"trailer" + | b"transfer-encoding" + | b"upgrade" + | b"user-agent" + | b"x-amzn-trace-id" + ) { + continue; + } + if strings::index_of_any(value, b"\r\n").is_some() + || strings::index_of_any(name, b"\r\n: ").is_some() + { + return Err(SignError::InvalidHeaderValue); + } + let mut v = Vec::with_capacity(value.len()); + trim_and_collapse_ws(value, &mut v); + if let Some(existing) = entries.iter_mut().find(|(n, _)| *n == lower) { + existing.1.push(b','); + existing.1.extend_from_slice(&v); + } else { + entries.push((lower, v)); + } + } + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let mut block = Vec::with_capacity(256); + let mut names = Vec::with_capacity(64); + for (i, (n, v)) in entries.iter().enumerate() { + block.extend_from_slice(n); + block.push(b':'); + block.extend_from_slice(v); + block.push(b'\n'); + if i > 0 { + names.push(b';'); + } + names.extend_from_slice(n); + } + Ok(CanonicalHeaders { block, names }) +} + +fn payload_hash(payload: Payload<'_>) -> Box<[u8]> { + match payload { + Payload::Unsigned => Box::from(UNSIGNED_PAYLOAD), + Payload::Sha256Hex(h) => Box::from(h), + Payload::Bytes([]) => Box::from(EMPTY_SHA256), + Payload::Bytes(b) => { + let mut digest = [0u8; SHA256::DIGEST]; + SHA256::hash(b, &mut digest); + format!("{}", hex_lower(&digest)) + .into_bytes() + .into_boxed_slice() + } + } +} + +const KEY_LEN: usize = 32; + +fn hmac(key: &[u8], data: &[u8]) -> Result<[u8; KEY_LEN], SignError> { + let mut buf = [0u8; EVP_MAX_MD_SIZE]; + let out = bun_sha_hmac::generate(key, data, bun_sha_hmac::Algorithm::Sha256, &mut buf) + .ok_or(SignError::FailedToGenerateSignature)?; + let mut k = [0u8; KEY_LEN]; + k.copy_from_slice(&out[..KEY_LEN]); + Ok(k) +} + +fn signing_key(secret: &[u8], date: &[u8], scope: Scope<'_>) -> Result<[u8; KEY_LEN], SignError> { + let mut k_secret = Vec::with_capacity(4 + secret.len()); + k_secret.extend_from_slice(b"AWS4"); + k_secret.extend_from_slice(secret); + let k_date = hmac(&k_secret, date)?; + bun_core::secure_zero_slice(&mut k_secret); + let k_region = hmac(&k_date, scope.region)?; + let k_service = hmac(&k_region, scope.service)?; + hmac(&k_service, b"aws4_request") +} + +fn validate(creds: &Credentials<'_>, req: &Request<'_>) -> Result<(), SignError> { + if creds.access_key_id.is_empty() || creds.secret_access_key.is_empty() { + return Err(SignError::MissingCredentials); + } + if req.scope.region.is_empty() || req.scope.service.is_empty() || req.host.is_empty() { + return Err(SignError::InvalidEndpoint); + } + let bad = |s: &[u8]| strings::index_of_any(s, b"\r\n").is_some(); + if bad(creds.access_key_id) + || creds.session_token.is_some_and(bad) + || bad(req.host) + || bad(req.path) + || bad(req.scope.region) + || bad(req.scope.service) + || bad(req.method) + { + return Err(SignError::InvalidHeaderValue); + } + if !req + .scope + .region + .iter() + .chain(req.scope.service.iter()) + .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.' | b'*')) + { + return Err(SignError::InvalidEndpoint); + } + Ok(()) +} + +struct StringToSign { + value: Vec, +} + +fn string_to_sign( + amz_date: &[u8; 16], + scope: Scope<'_>, + method: &[u8], + canonical_uri: &[u8], + canonical_query: &[u8], + headers: &CanonicalHeaders, + payload_hash: &[u8], +) -> StringToSign { + let mut canonical = Vec::with_capacity( + method.len() + canonical_uri.len() + canonical_query.len() + headers.block.len() + 160, + ); + canonical.extend_from_slice(method); + canonical.push(b'\n'); + canonical.extend_from_slice(canonical_uri); + canonical.push(b'\n'); + canonical.extend_from_slice(canonical_query); + canonical.push(b'\n'); + canonical.extend_from_slice(&headers.block); + canonical.push(b'\n'); + canonical.extend_from_slice(&headers.names); + canonical.push(b'\n'); + canonical.extend_from_slice(payload_hash); + + let mut digest = [0u8; SHA256::DIGEST]; + SHA256::hash(&canonical, &mut digest); + let mut value = Vec::with_capacity(160); + let _ = write!( + &mut value, + "AWS4-HMAC-SHA256\n{}\n{}/{}/{}/aws4_request\n{}", + BStr::new(amz_date), + BStr::new(&amz_date[..8]), + BStr::new(scope.region), + BStr::new(scope.service), + hex_lower(&digest) + ); + StringToSign { value } +} + +/// Header-form signature: returns the `Authorization`, `x-amz-date` and +/// `x-amz-content-sha256` values to attach. +pub fn sign(creds: &Credentials<'_>, req: &Request<'_>) -> Result { + validate(creds, req)?; + let s3 = req + .s3_path_semantics + .unwrap_or_else(|| is_s3_service(req.scope.service)); + let amz_date = req.datetime.unwrap_or_else(amz_datetime_now); + let payload = payload_hash(req.payload); + + let mut uri = Vec::with_capacity(req.path.len() + 8); + canonical_uri(&mut uri, req.path, s3); + let mut query = Vec::with_capacity(req.query.len() + 8); + canonical_query(&mut query, req.query, &[]); + // Only S3 wants `x-amz-content-sha256` on the wire; any `x-amz-*` header + // that is sent must be signed, so callers send it exactly when `s3`. + let headers = canonical_headers( + req.host, + Some(&amz_date), + if s3 { Some(&payload) } else { None }, + creds.session_token.filter(|t| !t.is_empty()), + req.headers, + )?; + let sts = string_to_sign( + &amz_date, req.scope, req.method, &uri, &query, &headers, &payload, + ); + let key = signing_key(creds.secret_access_key, &amz_date[..8], req.scope)?; + let signature = hmac(&key, &sts.value)?; + + let mut authorization = Vec::with_capacity(200 + headers.names.len()); + let _ = write!( + &mut authorization, + "AWS4-HMAC-SHA256 Credential={}/{}/{}/{}/aws4_request, SignedHeaders={}, Signature={}", + BStr::new(creds.access_key_id), + BStr::new(&amz_date[..8]), + BStr::new(req.scope.region), + BStr::new(req.scope.service), + BStr::new(&headers.names), + hex_lower(&signature) + ); + Ok(SignedRequest { + authorization: authorization.into_boxed_slice(), + amz_date, + content_sha256: payload, + send_content_sha256: s3, + }) +} + +/// Query-string form: returns `scheme://host/path?…&X-Amz-Signature=…`. +/// Only `host` (plus any `req.headers`) is signed. +pub fn presign( + creds: &Credentials<'_>, + req: &Request<'_>, + scheme: &[u8], + expires_in_seconds: u32, +) -> Result { + validate(creds, req)?; + if expires_in_seconds == 0 || expires_in_seconds > MAX_PRESIGN_EXPIRES { + return Err(SignError::InvalidExpires); + } + let s3 = req + .s3_path_semantics + .unwrap_or_else(|| is_s3_service(req.scope.service)); + let amz_date = req.datetime.unwrap_or_else(amz_datetime_now); + // S3 verifies query-authenticated requests against the literal + // UNSIGNED-PAYLOAD (the URL cannot carry a body hash); other services + // hash the body. + let payload: Box<[u8]> = if s3 { + Box::from(UNSIGNED_PAYLOAD) + } else { + payload_hash(req.payload) + }; + + let headers = canonical_headers(req.host, None, None, None, req.headers)?; + + let enc = |s: &[u8]| { + let mut v = Vec::with_capacity(s.len() + 8); + uri_encode_into(&mut v, s, false); + v + }; + let mut credential = Vec::with_capacity(64); + let _ = write!( + &mut credential, + "{}/{}/{}/{}/aws4_request", + BStr::new(creds.access_key_id), + BStr::new(&amz_date[..8]), + BStr::new(req.scope.region), + BStr::new(req.scope.service) + ); + let mut extra: Vec<(Vec, Vec)> = vec![ + (b"X-Amz-Algorithm".to_vec(), b"AWS4-HMAC-SHA256".to_vec()), + (b"X-Amz-Credential".to_vec(), enc(&credential)), + (b"X-Amz-Date".to_vec(), amz_date.to_vec()), + ( + b"X-Amz-Expires".to_vec(), + expires_in_seconds.to_string().into_bytes(), + ), + (b"X-Amz-SignedHeaders".to_vec(), enc(&headers.names)), + ]; + if let Some(token) = creds.session_token.filter(|t| !t.is_empty()) { + extra.push((b"X-Amz-Security-Token".to_vec(), enc(token))); + } + let mut uri = Vec::with_capacity(req.path.len() + 8); + canonical_uri(&mut uri, req.path, s3); + let mut query = Vec::with_capacity(req.query.len() + 256); + canonical_query(&mut query, req.query, &extra); + + let sts = string_to_sign( + &amz_date, req.scope, req.method, &uri, &query, &headers, &payload, + ); + let key = signing_key(creds.secret_access_key, &amz_date[..8], req.scope)?; + let signature = hmac(&key, &sts.value)?; + + let mut url = + Vec::with_capacity(scheme.len() + 3 + req.host.len() + uri.len() + query.len() + 82); + url.extend_from_slice(scheme); + url.extend_from_slice(b"://"); + url.extend_from_slice(req.host); + // Send the canonical path so the server recomputes the same thing. + url.extend_from_slice(if s3 { &uri } else { req.path }); + if !s3 && req.path.is_empty() { + url.push(b'/'); + } + url.push(b'?'); + url.extend_from_slice(&query); + let _ = write!(&mut url, "&X-Amz-Signature={}", hex_lower(&signature)); + Ok(PresignedUrl { + url: url.into_boxed_slice(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const AKID: &[u8] = b"AKIDEXAMPLE"; + const SECRET: &[u8] = b"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"; + + fn creds() -> Credentials<'static> { + Credentials { + access_key_id: AKID, + secret_access_key: SECRET, + session_token: None, + } + } + + fn dt() -> [u8; 16] { + *b"20150830T123600Z" + } + + // Vectors from the AWS SigV4 test suite (aws-sig-v4-test-suite). + #[test] + fn get_vanilla() { + let r = sign( + &creds(), + &Request { + method: b"GET", + host: b"example.amazonaws.com", + path: b"/", + query: b"", + headers: &[], + payload: Payload::Bytes(b""), + scope: Scope { + service: b"service", + region: b"us-east-1", + }, + datetime: Some(dt()), + s3_path_semantics: None, + }, + ) + .unwrap(); + assert_eq!( + BStr::new(&r.authorization), + BStr::new(b"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31".as_slice()) + ); + } + + #[test] + fn get_vanilla_query_order_key_case() { + let r = sign( + &creds(), + &Request { + method: b"GET", + host: b"example.amazonaws.com", + path: b"/", + query: b"Param2=value2&Param1=value1", + headers: &[], + payload: Payload::Bytes(b""), + scope: Scope { + service: b"service", + region: b"us-east-1", + }, + datetime: Some(dt()), + s3_path_semantics: None, + }, + ) + .unwrap(); + assert!(r.authorization.ends_with( + b"Signature=b97d918cfa904a5beff61c982a1b6f458b799221646efd99d3219ec94cdf2500" + )); + } + + #[test] + fn post_x_www_form_urlencoded() { + let r = sign( + &creds(), + &Request { + method: b"POST", + host: b"example.amazonaws.com", + path: b"/", + query: b"", + headers: &[(b"Content-Type", b"application/x-www-form-urlencoded")], + payload: Payload::Bytes(b"Param1=value1"), + scope: Scope { + service: b"service", + region: b"us-east-1", + }, + datetime: Some(dt()), + s3_path_semantics: None, + }, + ) + .unwrap(); + assert!(r.authorization.ends_with(b"SignedHeaders=content-type;host;x-amz-date, Signature=ff11897932ad3f4e8b18135d722051e5ac45fc38421b1da7b9d196a0fe09473a"), "{}", BStr::new(&r.authorization)); + } + + #[test] + fn get_with_normalized_path() { + // get-relative-relative: /example1/example2/../.. → / + let r = sign( + &creds(), + &Request { + method: b"GET", + host: b"example.amazonaws.com", + path: b"/example1/example2/../..", + query: b"", + headers: &[], + payload: Payload::Bytes(b""), + scope: Scope { + service: b"service", + region: b"us-east-1", + }, + datetime: Some(dt()), + s3_path_semantics: None, + }, + ) + .unwrap(); + assert!(r.authorization.ends_with( + b"Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31" + )); + } + + #[test] + fn iso8601() { + assert_eq!(parse_iso8601(b"1970-01-01T00:00:00Z"), Some(0)); + assert_eq!(parse_iso8601(b"2015-08-30T12:36:00Z"), Some(1_440_938_160)); + assert_eq!( + parse_iso8601(b"2015-08-30T12:36:00.123Z"), + Some(1_440_938_160) + ); + assert_eq!(parse_iso8601(b"20150830T123600Z"), Some(1_440_938_160)); + assert_eq!(parse_iso8601(b"20150830T123600+0500"), None); + assert_eq!(parse_iso8601(b"20150830T123600Zjunk"), None); + assert_eq!(parse_iso8601(b"20150830T123600\n"), None); + assert_eq!( + parse_iso8601(b"2015-08-30T14:36:00+02:00"), + Some(1_440_938_160) + ); + assert_eq!( + parse_iso8601(b"2015-08-30T14:36:00+0200"), + Some(1_440_938_160) + ); + assert_eq!( + parse_iso8601(b"2015-08-30T11:06:00.5-0130"), + Some(1_440_938_160) + ); + assert_eq!(parse_iso8601(b"2015-08-30T14:36:00+020"), None); + assert_eq!(parse_iso8601(b"2015-08-30T14:36:00++100"), None); + assert_eq!(parse_iso8601(b"2015-08-30T14:36:00+9999"), None); + assert_eq!(amz_datetime(1_440_938_160), *b"20150830T123600Z"); + assert_eq!(parse_iso8601(b"garbage"), None); + assert_eq!(parse_iso8601(b"20250230T000000Z"), None); + assert_eq!(parse_iso8601(b"2024-02-29T00:00:00Z"), Some(1_709_164_800)); + assert_eq!(parse_iso8601(b"2023-02-29T00:00:00Z"), None); + } + + #[test] + fn infer() { + let t = |h: &str| { + let (s, r) = infer_service_region(h.as_bytes()); + ( + s.map(|s| String::from_utf8(s.into_vec()).unwrap()), + r.map(|r| String::from_utf8(r.into_vec()).unwrap()), + ) + }; + assert_eq!( + t("dynamodb.us-west-2.amazonaws.com"), + (Some("dynamodb".into()), Some("us-west-2".into())) + ); + // A region-shaped bucket name on a regionless S3 host is a bucket. + assert_eq!( + t("my-data-1.s3.amazonaws.com"), + (Some("s3".into()), Some("us-east-1".into())) + ); + assert_eq!( + t("my-data-1.s3-accelerate.amazonaws.com"), + (Some("s3".into()), Some("us-east-1".into())) + ); + assert_eq!( + t("search-dom.eu-west-1.es.amazonaws.com"), + (Some("es".into()), Some("eu-west-1".into())) + ); + assert_eq!( + t("my-bucket.s3.us-west-004.backblazeb2.com"), + (Some("s3".into()), Some("us-west-004".into())) + ); + assert_eq!( + t("s3.eu-central-003.backblazeb2.com"), + (Some("s3".into()), Some("eu-central-003".into())) + ); + assert_eq!( + t("mydb.abc123.eu-west-1.rds.amazonaws.com"), + (Some("rds".into()), Some("eu-west-1".into())) + ); + assert_eq!( + t("eu-west-1.queue.amazonaws.com"), + (Some("sqs".into()), Some("eu-west-1".into())) + ); + assert_eq!( + t("myap-123456789012.s3-accesspoint.us-west-2.amazonaws.com"), + (Some("s3".into()), Some("us-west-2".into())) + ); + assert_eq!( + t("myap-123456789012.s3-accesspoint-fips.dualstack.us-west-2.amazonaws.com"), + (Some("s3".into()), Some("us-west-2".into())) + ); + assert_eq!(t("f004.backblazeb2.com"), (Some("s3".into()), None)); + assert_eq!( + t("bucket.s3-fips-us-gov-west-1.amazonaws.com"), + (Some("s3".into()), Some("us-gov-west-1".into())) + ); + assert_eq!( + t("sts.amazonaws.com"), + (Some("sts".into()), Some("us-east-1".into())) + ); + assert_eq!( + t("bucket.s3.eu-central-1.amazonaws.com"), + (Some("s3".into()), Some("eu-central-1".into())) + ); + assert_eq!( + t("bucket.s3.amazonaws.com"), + (Some("s3".into()), Some("us-east-1".into())) + ); + assert_eq!( + t("s3-us-west-2.amazonaws.com"), + (Some("s3".into()), Some("us-west-2".into())) + ); + assert_eq!( + t("s3.dualstack.us-east-2.amazonaws.com"), + (Some("s3".into()), Some("us-east-2".into())) + ); + assert_eq!( + t("abc.execute-api.ap-southeast-1.amazonaws.com"), + (Some("execute-api".into()), Some("ap-southeast-1".into())) + ); + assert_eq!( + t("xyz.lambda-url.us-east-1.on.aws"), + (Some("lambda".into()), Some("us-east-1".into())) + ); + assert_eq!( + t("email.us-east-1.amazonaws.com"), + (Some("ses".into()), Some("us-east-1".into())) + ); + assert_eq!( + t("acct.r2.cloudflarestorage.com"), + (Some("s3".into()), Some("auto".into())) + ); + assert_eq!( + t("bedrock-runtime.us-east-1.amazonaws.com"), + (Some("bedrock".into()), Some("us-east-1".into())) + ); + assert_eq!( + t("kms-fips.us-gov-west-1.amazonaws.com"), + (Some("kms".into()), Some("us-gov-west-1".into())) + ); + assert_eq!( + t("dynamodb.cn-north-1.amazonaws.com.cn"), + (Some("dynamodb".into()), Some("cn-north-1".into())) + ); + assert_eq!(t("localhost:9000"), (None, None)); + assert_eq!( + t("vpce-0a1b-xyz.sqs.us-west-2.vpce.amazonaws.com"), + (Some("sqs".into()), Some("us-west-2".into())) + ); + assert_eq!( + t("bucket.vpce-xx.s3.us-east-1.vpce.amazonaws.com"), + (Some("s3".into()), Some("us-east-1".into())) + ); + assert_eq!( + t("my-domain.eu-west-1.es.amazonaws.com"), + (Some("es".into()), Some("eu-west-1".into())) + ); + assert_eq!( + t("abc.eu-west-1.aoss.amazonaws.com"), + (Some("aoss".into()), Some("eu-west-1".into())) + ); + assert_eq!( + t("data-ats.iot.us-east-1.amazonaws.com"), + (Some("iotdata".into()), Some("us-east-1".into())) + ); + assert_eq!( + t("runtime.sagemaker.us-east-1.amazonaws.com"), + (Some("sagemaker".into()), Some("us-east-1".into())) + ); + assert_eq!( + t("api.ecr.us-east-1.amazonaws.com"), + (Some("ecr".into()), Some("us-east-1".into())) + ); + assert_eq!( + t("streams.dynamodb.us-east-1.amazonaws.com"), + (Some("dynamodb".into()), Some("us-east-1".into())) + ); + assert_eq!( + t("bucket.s3-website-us-east-1.amazonaws.com"), + (Some("s3".into()), Some("us-east-1".into())) + ); + assert_eq!( + t("s3-outposts.us-east-1.amazonaws.com"), + (Some("s3-outposts".into()), Some("us-east-1".into())) + ); + assert_eq!( + t("s3.eu-west-2.backblazeb2.com"), + (Some("s3".into()), Some("eu-west-2".into())) + ); + } + + #[test] + fn presign_vectors() { + let c = creds(); + // S3: canonical path in the URL, UNSIGNED-PAYLOAD, session token echoed. + let session = Credentials { + session_token: Some(b"tok en"), + ..c + }; + let req = Request { + method: b"GET", + host: b"examplebucket.s3.amazonaws.com", + path: b"/test file.txt", + query: b"", + headers: &[], + payload: Payload::Unsigned, + scope: Scope { + service: b"s3", + region: b"us-east-1", + }, + datetime: Some(dt()), + s3_path_semantics: None, + }; + let url = presign(&session, &req, b"https", 86400).unwrap().url; + let url = std::str::from_utf8(&url).unwrap(); + assert!(url.starts_with("https://examplebucket.s3.amazonaws.com/test%20file.txt?")); + assert!(url.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256")); + assert!( + url.contains("X-Amz-Credential=AKIDEXAMPLE%2F20150830%2Fus-east-1%2Fs3%2Faws4_request") + ); + assert!(url.contains("X-Amz-Date=20150830T123600Z&X-Amz-Expires=86400")); + assert!(url.contains("X-Amz-Security-Token=tok%20en")); + assert!(url.contains("X-Amz-SignedHeaders=host&")); + assert_eq!(url.len() - url.rfind("X-Amz-Signature=").unwrap(), 16 + 64); + // Deterministic for a fixed datetime. + assert_eq!( + presign(&session, &req, b"https", 86400).unwrap().url, + presign(&session, &req, b"https", 86400).unwrap().url + ); + + // Non-S3: empty path becomes "/", existing query is kept and signed. + let iam = Request { + host: b"iam.amazonaws.com", + path: b"", + query: b"Action=ListUsers&Version=2010-05-08", + payload: Payload::Bytes(b""), + scope: Scope { + service: b"iam", + region: b"us-east-1", + }, + ..req + }; + let url = presign(&c, &iam, b"https", 60).unwrap().url; + let url = std::str::from_utf8(&url).unwrap(); + assert!(url.starts_with( + "https://iam.amazonaws.com/?Action=ListUsers&Version=2010-05-08&X-Amz-Algorithm=" + )); + assert!(!url.contains("X-Amz-Security-Token")); + + assert_eq!( + presign(&c, &req, b"https", 0).err(), + Some(SignError::InvalidExpires) + ); + assert_eq!( + presign(&c, &req, b"https", MAX_PRESIGN_EXPIRES + 1).err(), + Some(SignError::InvalidExpires) + ); + let crlf = Request { + path: b"/a\r\nb", + ..req + }; + assert!(presign(&c, &crlf, b"https", 60).is_err()); + } +} diff --git a/src/spawn/lib.rs b/src/spawn/lib.rs index 4725121ebca5..66af25d3c1a6 100644 --- a/src/spawn/lib.rs +++ b/src/spawn/lib.rs @@ -250,6 +250,11 @@ pub enum Term { pub struct RunOptions<'a> { pub argv: &'a [&'a [u8]], pub env_map: &'a bun_sys::EnvMap, + /// Windows: append `argv[1..]` to the command line verbatim instead of + /// quoting each one (needed for `cmd.exe /C `), so quotes, + /// `&`, `|` … in them reach cmd.exe as written — only for arguments the + /// caller fully controls. Ignored elsewhere. + pub windows_verbatim_arguments: bool, } /// Result of [`run`]. @@ -298,7 +303,12 @@ pub fn run(opts: RunOptions<'_>) -> crate::Result { // `Command::new` does PATH/PATHEXT lookup on Windows. let mut cmd = std::process::Command::new(to_os(argv0)); for arg in iter { - cmd.arg(to_os(arg)); + if opts.windows_verbatim_arguments { + use std::os::windows::process::CommandExt as _; + cmd.raw_arg(to_os(arg)); + } else { + cmd.arg(to_os(arg)); + } } cmd.env_clear(); for (k, v) in opts.env_map { diff --git a/src/url/lib.rs b/src/url/lib.rs index 8c02d641839a..30a4544df336 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -366,6 +366,23 @@ impl<'a> URL<'a> { } } + /// The query string including its leading `?` (empty if none). + #[inline] + pub fn search(&self) -> &'a [u8] { + self.search + } + + /// The path exactly as it appears in `href` (percent-encoding untouched), + /// without query or fragment; `/` when empty. Unlike `path`, short paths + /// such as `/a` are preserved. + pub fn raw_pathname(&self) -> &'a [u8] { + let p = self + .pathname + .strip_suffix(self.search) + .unwrap_or(self.pathname); + if p.is_empty() { b"/" } else { p } + } + pub fn s3_path(&self) -> &'a [u8] { if !self.protocol.is_empty() && self.href.len() > self.protocol.len() + 2 { &self.href[self.protocol.len() + 2..] diff --git a/test/harness.ts b/test/harness.ts index b3167919ec84..8060677b53cc 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -87,8 +87,45 @@ export const bunEnv: NodeJS.Dict = { BUN_DEBUG_linkerctx: "0", WANTS_LOUD: "0", AGENT: "false", + // Keep the AWS default credential chain from picking up the CI agent's + // instance role or a developer's ~/.aws; tests that want it opt back in. + AWS_EC2_METADATA_DISABLED: "true", + AWS_CONFIG_FILE: process.env.BUN_TEST_AWS_CONFIG_FILE || "/dev/null/aws-config", + AWS_SHARED_CREDENTIALS_FILE: process.env.BUN_TEST_AWS_SHARED_CREDENTIALS_FILE || "/dev/null/aws-credentials", }; +/** + * Make *this* process (not just spawned ones, which get `bunEnv`) blind to the + * ambient AWS credential chain: a developer's `~/.aws`, a CI agent's instance + * role, container / web-identity endpoints. For in-process `Bun.s3` / + * `S3Client` tests that assert on the no-credentials path. + * + * Static `AWS_ACCESS_KEY_ID` / `S3_*` keys are read once at startup, so deleting + * them here only affects child processes; a shell that exports them should run + * such tests through `bunEnv` subprocesses instead. + */ +export function isolateAwsCredentialChain(env: Record = process.env) { + env.AWS_EC2_METADATA_DISABLED = "true"; + env.AWS_CONFIG_FILE = bunEnv.AWS_CONFIG_FILE; + env.AWS_SHARED_CREDENTIALS_FILE = bunEnv.AWS_SHARED_CREDENTIALS_FILE; + for (const key of [ + "AWS_PROFILE", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "S3_ACCESS_KEY_ID", + "S3_SECRET_ACCESS_KEY", + "S3_SESSION_TOKEN", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_AUTHORIZATION_TOKEN", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AWS_ROLE_ARN", + ]) { + delete env[key]; + } +} + const ciEnv = { ...bunEnv }; if (isASAN) { diff --git a/test/integration/bun-types/fixture/s3.ts b/test/integration/bun-types/fixture/s3.ts index 7f9186bce414..2f69cb799ce1 100644 --- a/test/integration/bun-types/fixture/s3.ts +++ b/test/integration/bun-types/fixture/s3.ts @@ -29,3 +29,34 @@ doFileOps( type: "application/octet-stream", }), ); + +// Ambient AWS / GCP credentials +{ + const client = new Bun.S3Client({ profile: "prod", bucket: "b" }); + client.file("x").presign(); + const creds: Bun.AWSCredentials = await Bun.aws.credentials({ profile: "prod", refresh: true }); + console.log(creds.accessKeyId, creds.secretAccessKey, creds.sessionToken, creds.expiration?.getTime(), creds.source); + const url: Promise = Bun.aws.presign("https://b.s3.amazonaws.com/k", { expiresIn: 60, method: "PUT" }); + await Bun.aws.fetch("https://sqs.us-east-1.amazonaws.com/"); + await Bun.aws.fetch("/?Action=ListQueues", { service: "sqs", method: "GET" }); + for await (const m of Bun.aws.eventStream(await Bun.aws.fetch("https://bedrock-runtime.us-east-1.amazonaws.com/x"))) { + const h: string | number | bigint | boolean | Date | Uint8Array | undefined = m.headers[":event-type"]; + console.log(m.type, m.event, m.contentType, m.payload.byteLength, m.text(), m.json(), h); + } + const prod = new Bun.AWSClient({ profile: "prod", region: "eu-west-1", endpoint: "http://localhost:4566" }); + const r: Response = await prod.fetch("https://example.com/", { + service: "execute-api", + signQuery: true, + body: "x", + method: "POST", + }); + console.log(prod.region, prod.profile, r.status, Bun.aws instanceof Bun.AWSClient); + const t: Bun.GCPToken = await Bun.gcp.accessToken({ scopes: ["cloud-platform"] }); + console.log(t.token, t.expiration.getTime(), t.source, t.email, t.projectId, t.quotaProjectId, url); + await Bun.gcp.idToken("https://run.app"); + await Bun.gcp.idToken({ audience: "https://run.app" }); + await Bun.gcp.fetch("https://storage.googleapis.com/"); + const sa = new Bun.GCPClient({ keyFile: "/x.json", scopes: "devstorage.read_only" }); + await sa.fetch("https://x.run.app/", { audience: "https://x.run.app", method: "POST", body: "{}" }); + await new Bun.GCPClient({ credentials: { type: "service_account" }, audience: "https://x" }).idToken(); +} diff --git a/test/internal/source-lints/vm-thread-door.inventory.json b/test/internal/source-lints/vm-thread-door.inventory.json index 942c25664315..74d2f8dcd1e0 100644 --- a/test/internal/source-lints/vm-thread-door.inventory.json +++ b/test/internal/source-lints/vm-thread-door.inventory.json @@ -253,11 +253,14 @@ "WriteFile" ] }, + "src/runtime/webcore/cloud/io.rs": { + "thread spawn": 1 + }, "src/runtime/webcore/fetch/FetchTasklet.rs": { "HTTPThread::schedule": 1 }, "src/runtime/webcore/s3/client.rs": { - "HTTPThread::schedule": 2 + "HTTPThread::schedule": 1 }, "src/runtime/webcore/s3/simple_request.rs": { "HTTPThread::schedule": 1 diff --git a/test/js/bun/aws/aws-credentials.test.ts b/test/js/bun/aws/aws-credentials.test.ts new file mode 100644 index 000000000000..6251ac24b0b3 --- /dev/null +++ b/test/js/bun/aws/aws-credentials.test.ts @@ -0,0 +1,1005 @@ +import type { Server } from "bun"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { chmodSync } from "fs"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { join } from "path"; + +// Every AWS_* variable the chain reads, so nothing ambient on the machine +// running the tests (a developer's ~/.aws, a CI agent's instance role) leaks in. +const STRIPPED = [ + "AWS_PROFILE", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_ACCOUNT_ID", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_CONFIG_FILE", + "AWS_SHARED_CREDENTIALS_FILE", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AWS_ROLE_ARN", + "AWS_ROLE_SESSION_NAME", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_AUTHORIZATION_TOKEN", + "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", + "AWS_EC2_METADATA_DISABLED", + "AWS_EC2_METADATA_SERVICE_ENDPOINT", + "AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE", + "AWS_EC2_METADATA_V1_DISABLED", + "AWS_METADATA_SERVICE_TIMEOUT", + "AWS_METADATA_SERVICE_NUM_ATTEMPTS", + "AWS_ENDPOINT_URL", + "AWS_ENDPOINT_URL_STS", + "AWS_STS_REGIONAL_ENDPOINTS", + "S3_ACCESS_KEY_ID", + "S3_SECRET_ACCESS_KEY", + "S3_SESSION_TOKEN", + "S3_REGION", + "S3_ENDPOINT", + "S3_BUCKET", + "AWS_ENDPOINT", + "AWS_BUCKET", + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", +]; + +let home: ReturnType; +let baseEnv: Record; + +// ── mock endpoints ───────────────────────────────────────────────────────── + +type Hit = { method: string; path: string; headers: Record; body: string }; +let imds: Server, sts: Server, container: Server, s3: Server; +const hits = { imds: [] as Hit[], sts: [] as Hit[], container: [] as Hit[], s3: [] as Hit[] }; +const imdsCredsExpiration = "2099-01-01T00:00:00Z"; +const imdsRole = "my-instance-role"; + +async function record(list: Hit[], req: Request) { + const hit = { + method: req.method, + path: new URL(req.url).pathname + new URL(req.url).search, + headers: Object.fromEntries(req.headers), + body: await req.text(), + }; + list.push(hit); + return hit; +} + +function stsXml(action: string, akid: string) { + return `<${action}Response xmlns="https://sts.amazonaws.com/doc/2011-06-15/"> + <${action}Result> + + ${akid} + sts-secret + sts-session-token + 2099-06-01T00:00:00Z + + +`; +} + +beforeAll(() => { + home = tempDir("aws-home", { ".aws": { placeholder: "" } }); + baseEnv = { ...bunEnv, HOME: String(home), USERPROFILE: String(home), AWS_EC2_METADATA_DISABLED: "true" }; + for (const k of STRIPPED) baseEnv[k] = undefined; + baseEnv.AWS_EC2_METADATA_DISABLED = "true"; + + imds = Bun.serve({ + port: 0, + async fetch(req) { + const hit = await record(hits.imds, req); + if (hit.path === "/latest/api/token") { + if (req.method !== "PUT") return new Response("bad", { status: 405 }); + return new Response("IMDS-TOKEN"); + } + if (req.headers.get("x-aws-ec2-metadata-token") !== "IMDS-TOKEN") { + return new Response("unauthorized", { status: 401 }); + } + if (hit.path === "/latest/meta-data/iam/security-credentials/") { + return new Response(imdsRole + "\n"); + } + if (hit.path === "/latest/meta-data/iam/security-credentials/" + imdsRole) { + return Response.json({ + Code: "Success", + Type: "AWS-HMAC", + AccessKeyId: "ASIAIMDS", + SecretAccessKey: "imds-secret", + Token: "imds-token", + Expiration: imdsCredsExpiration, + LastUpdated: "2020-01-01T00:00:00Z", + }); + } + return new Response("??", { status: 404 }); + }, + }); + + sts = Bun.serve({ + port: 0, + async fetch(req) { + const hit = await record(hits.sts, req); + const params = new URLSearchParams(hit.body); + const action = params.get("Action"); + if (params.get("RoleArn")?.includes("denied")) { + return new Response( + `SenderAccessDeniednot today`, + { status: 403, headers: { "content-type": "text/xml" } }, + ); + } + if (action === "AssumeRole") return new Response(stsXml("AssumeRole", "ASIAASSUMED")); + if (action === "AssumeRoleWithWebIdentity") + return new Response(stsXml("AssumeRoleWithWebIdentity", "ASIAWEBIDENT")); + return new Response("unknown action", { status: 400 }); + }, + }); + + container = Bun.serve({ + port: 0, + async fetch(req) { + await record(hits.container, req); + if (req.headers.get("authorization") !== "container-auth-token") { + return new Response("missing auth", { status: 403 }); + } + return Response.json({ + AccessKeyId: "ASIACONTAINER", + SecretAccessKey: "container-secret", + Token: "container-token", + Expiration: "2099-02-03T04:05:06Z", + AccountId: "123456789012", + }); + }, + }); + + s3 = Bun.serve({ + port: 0, + async fetch(req) { + await record(hits.s3, req); + if (req.method === "PUT") return new Response(null, { status: 200, headers: { etag: '"abc"' } }); + if (req.method === "HEAD") return new Response(null, { headers: { "content-length": "5", etag: '"abc"' } }); + return new Response("hello", { headers: { etag: '"abc"' } }); + }, + }); +}); + +afterAll(() => { + for (const s of [imds, sts, container, s3]) s?.stop(true); + home?.[Symbol.dispose](); +}); + +async function run(code: string, env: Record) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: { ...baseEnv, ...env } as any, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +const CREDS_SCRIPT = (opts = "") => ` + try { + const c = await Bun.aws.credentials(${opts}); + console.log(JSON.stringify({ ...c, expiration: c.expiration?.toISOString() })); + } catch (e) { + console.log(JSON.stringify({ error: { code: e.code, message: e.message } })); + } +`; + +async function creds(env: Record, opts = "") { + const { stdout, stderr, exitCode } = await run(CREDS_SCRIPT(opts), env); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + return JSON.parse(stdout.trim()); +} + +function writeAwsFiles(dirName: string, files: { config?: string; credentials?: string }) { + const dir = tempDir(dirName, { + config: files.config ?? "", + credentials: files.credentials ?? "", + }); + return { + dir, + env: { + AWS_CONFIG_FILE: join(dir, "config"), + AWS_SHARED_CREDENTIALS_FILE: join(dir, "credentials"), + }, + }; +} + +// ── the chain ────────────────────────────────────────────────────────────── + +describe.concurrent("Bun.aws.credentials", () => { + test("nothing configured → ERR_AWS_MISSING_CREDENTIALS naming every source it tried", async () => { + const result = await creds({}); + expect(result.error.code).toBe("ERR_AWS_MISSING_CREDENTIALS"); + expect(result.error.message).toContain("AWS_ACCESS_KEY_ID"); + expect(result.error.message).toContain('profile "default"'); + expect(result.error.message).toContain("AWS_WEB_IDENTITY_TOKEN_FILE"); + expect(result.error.message).toContain("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"); + expect(result.error.message).toContain("AWS_EC2_METADATA_DISABLED"); + }); + + test("environment variables", async () => { + const result = await creds({ + AWS_ACCESS_KEY_ID: "AKIAENV", + AWS_SECRET_ACCESS_KEY: "env-secret", + AWS_SESSION_TOKEN: "env-token", + AWS_REGION: "eu-west-1", + }); + expect(result).toEqual({ + accessKeyId: "AKIAENV", + secretAccessKey: "env-secret", + sessionToken: "env-token", + region: "eu-west-1", + source: "env", + }); + }); + + test("runtime writes to process.env are honoured", async () => { + const { stdout, exitCode } = await run( + `process.env.AWS_ACCESS_KEY_ID = "AKIARUNTIME"; process.env.AWS_SECRET_ACCESS_KEY = "s";` + CREDS_SCRIPT(), + {}, + ); + expect(JSON.parse(stdout.trim())).toMatchObject({ accessKeyId: "AKIARUNTIME", source: "env" }); + expect(exitCode).toBe(0); + }); + + test("Bun.aws re-reads the environment on refresh (env keys are not frozen at first use)", async () => { + const { stdout, exitCode } = await run( + ` + const a = await Bun.aws.credentials(); + process.env.AWS_ACCESS_KEY_ID = "AKIAROTATED"; + process.env.AWS_REGION = "ap-south-1"; + const cached = await Bun.aws.credentials(); + const b = await Bun.aws.credentials({ refresh: true }); + console.log(a.accessKeyId, a.accountId, cached.accessKeyId, b.accessKeyId, Bun.aws.region); + `, + { AWS_ACCESS_KEY_ID: "AKIAFIRST", AWS_SECRET_ACCESS_KEY: "s", AWS_ACCOUNT_ID: "111122223333" }, + ); + expect(stdout.trim()).toBe("AKIAFIRST 111122223333 AKIAFIRST AKIAROTATED ap-south-1"); + expect(exitCode).toBe(0); + }); + + test("static profile in ~/.aws/credentials, region from ~/.aws/config", async () => { + using dir = tempDir("aws-static-profile", { + ".aws": { + // [default] is indented the way some editors leave it; [other] has an inline comment + credentials: `[default]\n aws_access_key_id = AKIADEFAULT\n aws_secret_access_key = default-secret\n\n[other]\naws_access_key_id=AKIAOTHER\naws_secret_access_key=other-secret\n# a comment line\naws_session_token = other;token #1\n`, + config: `[default]\nregion = ap-south-1\n\n[profile other]\nregion=us-west-2\ns3 =\n max_concurrent_requests = 20\n`, + }, + }); + expect(await creds({ HOME: dir, USERPROFILE: dir })).toEqual({ + accessKeyId: "AKIADEFAULT", + secretAccessKey: "default-secret", + region: "ap-south-1", + source: "profile", + }); + // AWS_PROFILE picks the profile + expect(await creds({ HOME: dir, USERPROFILE: dir, AWS_PROFILE: "other" })).toEqual({ + accessKeyId: "AKIAOTHER", + secretAccessKey: "other-secret", + // inline comments follow the AWS SDK for JavaScript: ` #1` is a comment, `;token` is not + sessionToken: "other;token", + region: "us-west-2", + source: "profile", + }); + // …and so does the option. A selected profile beats exported keys (as in the SDKs). + expect( + await creds( + { HOME: dir, USERPROFILE: dir, AWS_ACCESS_KEY_ID: "AKIAENV", AWS_SECRET_ACCESS_KEY: "x" }, + `{ profile: "other" }`, + ), + ).toMatchObject({ accessKeyId: "AKIAOTHER", source: "profile" }); + expect( + await creds({ + HOME: dir, + USERPROFILE: dir, + AWS_PROFILE: "other", + AWS_ACCESS_KEY_ID: "AKIAENV", + AWS_SECRET_ACCESS_KEY: "x", + }), + ).toMatchObject({ accessKeyId: "AKIAOTHER", source: "profile" }); + // without a profile selection, env keys win over [default] + expect( + await creds({ HOME: dir, USERPROFILE: dir, AWS_ACCESS_KEY_ID: "AKIAENV", AWS_SECRET_ACCESS_KEY: "x" }), + ).toMatchObject({ accessKeyId: "AKIAENV", source: "env" }); + // an explicitly named profile that does not exist is an error, not a fallthrough + const missing = await creds({ HOME: dir, USERPROFILE: dir }, `{ profile: "nope" }`); + expect(missing.error.code).toBe("ERR_AWS_CREDENTIALS"); + expect(missing.error.message).toContain('profile "nope" was not found'); + expect(missing.error.message).not.toContain("could not read"); + }); + + test("a shared config file that exists but cannot be read is called out", async () => { + using dir = tempDir("aws-home-unreadable", { + ".aws": { + // a fully-keyed [default] must not be fallen back to when the named profile is missing + credentials: `[default]\naws_access_key_id = AKIADEFAULT\naws_secret_access_key = x\n[lonely]\nregion = us-east-1\n[dev]\nsso_session = corp\nsso_account_id = 1\nsso_role_name = r\n`, + }, + }); + using unreadable = tempDir("aws-unreadable", { config: { "not-a-file": "" } }); // a directory + const env = { HOME: dir, USERPROFILE: dir, AWS_CONFIG_FILE: join(unreadable, "config") }; + const [blocked, ambient, lonely, dev] = await Promise.all([ + creds(env, `{ profile: "nope" }`), + creds({ ...env, AWS_PROFILE: "nope" }), + creds(env, `{ profile: "lonely" }`), + creds(env, `{ profile: "dev" }`), + ]); + expect(blocked.error.message).toMatch(/profile "nope" was not found.*; could not read .*config \(E[A-Z]+\)/); + expect(ambient.error.code).toBe("ERR_AWS_MISSING_CREDENTIALS"); + expect(ambient.error.message).toMatch(/config \(could not be read: E[A-Z]+\); profile "nope" \(not found in/); + expect(lonely.error.message).toMatch(/does not contain credentials.*; could not read .*config \(E[A-Z]+\)/); + expect(dev.error.message).toMatch(/sso-session corp.*; could not read .*config \(E[A-Z]+\)/); + }); + + test("AWS_SHARED_CREDENTIALS_FILE / AWS_CONFIG_FILE override the default paths", async () => { + const { dir, env } = writeAwsFiles("aws-file-override", { + credentials: `[default]\naws_access_key_id = AKIAFROMFILE\naws_secret_access_key = file-secret\n`, + }); + using _ = dir; + expect(await creds(env)).toMatchObject({ accessKeyId: "AKIAFROMFILE", source: "profile" }); + }); + + test.skipIf(isWindows)("credential_process", async () => { + using bin = tempDir("aws-credproc", { + "creds.sh": `#!/bin/sh\necho '{"Version": 1, "AccessKeyId": "ASIAPROCESS", "SecretAccessKey": "process-secret", "SessionToken": "process-token", "Expiration": "2099-03-04T05:06:07Z", "AccountId": "111122223333"}'\n`, + "bad.sh": `#!/bin/sh\necho 'this went wrong' >&2\nexit 3\n`, + }); + chmodSync(join(bin, "creds.sh"), 0o755); + chmodSync(join(bin, "bad.sh"), 0o755); + const ok = writeAwsFiles("aws-credproc-cfg", { + config: `[default]\ncredential_process = ${join(bin, "creds.sh")} --some-arg\nregion = us-east-2\n[profile bad]\ncredential_process = ${join(bin, "bad.sh")}\n`, + }); + using _ = ok.dir; + expect(await creds(ok.env)).toEqual({ + accessKeyId: "ASIAPROCESS", + secretAccessKey: "process-secret", + sessionToken: "process-token", + expiration: "2099-03-04T05:06:07.000Z", + accountId: "111122223333", + region: "us-east-2", + source: "process", + }); + const bad = await creds(ok.env, `{ profile: "bad" }`); + expect(bad.error.code).toBe("ERR_AWS_CREDENTIALS"); + expect(bad.error.message).toContain("credential_process exited with"); + expect(bad.error.message).toContain("this went wrong"); + }); + + test.skipIf(isWindows)("credentials that arrive already expired are an error, not cached", async () => { + using bin = tempDir("aws-credproc-expired", { + "old.sh": `#!/bin/sh\necho '{"Version": 1, "AccessKeyId": "ASIAOLD", "SecretAccessKey": "s", "SessionToken": "t", "Expiration": "2020-01-01T00:00:00Z"}'\n`, + }); + chmodSync(join(bin, "old.sh"), 0o755); + const files = writeAwsFiles("aws-credproc-expired-cfg", { + config: `[default]\ncredential_process = ${join(bin, "old.sh")}\n`, + }); + using _ = files.dir; + const result = await creds(files.env); + expect(result.error.code).toBe("ERR_AWS_CREDENTIALS"); + expect(result.error.message).toMatch(/credentials from process were already expired .*2020-?01-?01/); + }); + + test("role_arn + source_profile → STS AssumeRole signed with the source profile's keys", async () => { + const files = writeAwsFiles("aws-assume-role", { + credentials: `[base]\naws_access_key_id = AKIABASE\naws_secret_access_key = base-secret\n`, + config: `[profile app]\nrole_arn = arn:aws:iam::123456789012:role/app\nsource_profile = base\nrole_session_name = my-session\nexternal_id = ext-42\nduration_seconds = 1800\nregion = eu-central-1\n\n[profile denied]\nrole_arn = arn:aws:iam::123456789012:role/denied\nsource_profile = base\n\n[profile loop1]\nrole_arn = arn:aws:iam::1:role/x\nsource_profile = loop2\n[profile loop2]\nrole_arn = arn:aws:iam::1:role/y\nsource_profile = loop1\n`, + }); + using _ = files.dir; + const env = { ...files.env, AWS_ENDPOINT_URL_STS: sts.url.href, AWS_PROFILE: "app" }; + const before = hits.sts.length; + const result = await creds(env); + expect(result).toEqual({ + accessKeyId: "ASIAASSUMED", + secretAccessKey: "sts-secret", + sessionToken: "sts-session-token", + expiration: "2099-06-01T00:00:00.000Z", + region: "eu-central-1", + source: "assume-role", + }); + // (other tests in this concurrent block hit the same mock STS with AssumeRoleWithWebIdentity) + const hit = hits.sts.slice(before).find(h => new URLSearchParams(h.body).get("Action") === "AssumeRole")!; + expect(hit.method).toBe("POST"); + const body = new URLSearchParams(hit.body); + expect(body.get("RoleArn")).toBe("arn:aws:iam::123456789012:role/app"); + expect(body.get("RoleSessionName")).toBe("my-session"); + expect(body.get("ExternalId")).toBe("ext-42"); + expect(body.get("DurationSeconds")).toBe("1800"); + expect(body.get("Version")).toBe("2011-06-15"); + expect(hit.headers.authorization).toStartWith("AWS4-HMAC-SHA256 Credential=AKIABASE/"); + expect(hit.headers.authorization).toContain("/eu-central-1/sts/aws4_request"); + expect(hit.headers["x-amz-date"]).toMatch(/^\d{8}T\d{6}Z$/); + + // STS error surfaces with the service's Code/Message + const denied = await creds({ ...env, AWS_PROFILE: "denied" }); + expect(denied.error.code).toBe("ERR_AWS_CREDENTIALS"); + expect(denied.error.message).toContain("AccessDenied"); + expect(denied.error.message).toContain("not today"); + + // cycles are detected + const loop = await creds({ ...env, AWS_PROFILE: "loop1" }); + expect(loop.error.message).toContain("loops back on itself"); + }); + + test("AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN → STS AssumeRoleWithWebIdentity (unsigned)", async () => { + using dir = tempDir("aws-web-identity", { token: " eyJhbGciOi.fake.jwt\n" }); + const before = hits.sts.length; + const result = await creds({ + AWS_WEB_IDENTITY_TOKEN_FILE: join(dir, "token"), + AWS_ROLE_ARN: "arn:aws:iam::123456789012:role/eks-pod", + AWS_ROLE_SESSION_NAME: "pod-session", + AWS_ENDPOINT_URL_STS: sts.url.href, + AWS_REGION: "us-west-2", + }); + expect(result).toEqual({ + accessKeyId: "ASIAWEBIDENT", + secretAccessKey: "sts-secret", + sessionToken: "sts-session-token", + expiration: "2099-06-01T00:00:00.000Z", + region: "us-west-2", + source: "web-identity", + }); + const hit = hits.sts.slice(before).find(h => h.body.includes("AssumeRoleWithWebIdentity"))!; + const body = new URLSearchParams(hit.body); + expect(body.get("WebIdentityToken")).toBe("eyJhbGciOi.fake.jwt"); + expect(body.get("RoleArn")).toBe("arn:aws:iam::123456789012:role/eks-pod"); + expect(body.get("RoleSessionName")).toBe("pod-session"); + expect(hit.headers.authorization).toBeUndefined(); + + // a profile can point at a token file too + const files = writeAwsFiles("aws-web-identity-profile", { + config: `[default]\nweb_identity_token_file = ${join(dir, "token")}\nrole_arn = arn:aws:iam::1:role/from-profile\n`, + }); + using _ = files.dir; + expect(await creds({ ...files.env, AWS_ENDPOINT_URL_STS: sts.url.href })).toMatchObject({ + accessKeyId: "ASIAWEBIDENT", + source: "web-identity", + }); + }); + + test("container credentials (AWS_CONTAINER_CREDENTIALS_FULL_URI + token file)", async () => { + using dir = tempDir("aws-container", { token: "container-auth-token\n" }); + const result = await creds({ + AWS_CONTAINER_CREDENTIALS_FULL_URI: `http://127.0.0.1:${container.port}/v2/credentials?x=1`, + AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE: join(dir, "token"), + }); + expect(result).toEqual({ + accessKeyId: "ASIACONTAINER", + secretAccessKey: "container-secret", + sessionToken: "container-token", + expiration: "2099-02-03T04:05:06.000Z", + accountId: "123456789012", + source: "container", + }); + expect( + hits.container.some(h => h.path === "/v2/credentials?x=1" && h.headers.authorization === "container-auth-token"), + ).toBe(true); + + // the env token works too; a wrong one is a hard error (configured but failing) + const wrong = await creds({ + AWS_CONTAINER_CREDENTIALS_FULL_URI: `http://localhost:${container.port}/`, + AWS_CONTAINER_AUTHORIZATION_TOKEN: "wrong", + }); + expect(wrong.error.code).toBe("ERR_AWS_CREDENTIALS"); + expect(wrong.error.message).toContain("HTTP 403"); + + // non-loopback plain-http hosts are refused without a request being made + const refused = await creds({ AWS_CONTAINER_CREDENTIALS_FULL_URI: "http://example.com/creds" }); + expect(refused.error.message).toContain("must be https://"); + }); + + test("EC2 instance metadata (IMDSv2)", async () => { + const before = hits.imds.length; + const result = await creds({ + AWS_EC2_METADATA_DISABLED: undefined, + AWS_EC2_METADATA_SERVICE_ENDPOINT: imds.url.href, + }); + expect(result).toEqual({ + accessKeyId: "ASIAIMDS", + secretAccessKey: "imds-secret", + sessionToken: "imds-token", + expiration: "2099-01-01T00:00:00.000Z", + source: "imds", + }); + const mine = hits.imds.slice(before); + expect(mine.map(h => `${h.method} ${h.path}`)).toEqual([ + "PUT /latest/api/token", + "GET /latest/meta-data/iam/security-credentials/", + "GET /latest/meta-data/iam/security-credentials/my-instance-role", + ]); + expect(mine[0].headers["x-aws-ec2-metadata-token-ttl-seconds"]).toBe("21600"); + expect(mine[2].headers["x-aws-ec2-metadata-token"]).toBe("IMDS-TOKEN"); + }); + + test("IMDSv1 fallback when the IMDSv2 token request gets no answer (container hop limit)", async () => { + const seen: string[] = []; + using hung = Bun.serve({ + port: 0, + async fetch(req) { + const path = new URL(req.url).pathname; + seen.push(`${req.method} ${path} token=${req.headers.get("x-aws-ec2-metadata-token") ?? ""}`); + if (req.method === "PUT") return new Promise(() => {}); // never answers + if (path.endsWith("/security-credentials/")) return new Response("role"); + return Response.json({ + Code: "Success", + AccessKeyId: "ASIAV1", + SecretAccessKey: "s", + Token: "t", + Expiration: "2099-01-01T00:00:00Z", + }); + }, + }); + const result = await creds({ + AWS_EC2_METADATA_DISABLED: undefined, + AWS_EC2_METADATA_SERVICE_ENDPOINT: hung.url.href, + AWS_METADATA_SERVICE_TIMEOUT: "0.3", + }); + expect(result).toMatchObject({ accessKeyId: "ASIAV1", source: "imds" }); + expect(seen).toEqual([ + "PUT /latest/api/token token=", + "GET /latest/meta-data/iam/security-credentials/ token=", + "GET /latest/meta-data/iam/security-credentials/role token=", + ]); + // …unless v1 is disabled + const disabled = await creds({ + AWS_EC2_METADATA_DISABLED: undefined, + AWS_EC2_METADATA_SERVICE_ENDPOINT: hung.url.href, + AWS_METADATA_SERVICE_TIMEOUT: "0.3", + AWS_EC2_METADATA_V1_DISABLED: "true", + }); + expect(disabled.error.code).toBe("ERR_AWS_MISSING_CREDENTIALS"); + }); + + test("a metadata request that times out while still queued behind other requests settles (as a timeout)", async () => { + using hang = Bun.serve({ port: 0, fetch: () => new Promise(() => {}) }); + // One HTTP slot, held by a request that never answers: the IMDS deadline + // fires before the metadata request ever connects. + const { stdout, exitCode } = await run( + ` + fetch(${JSON.stringify(hang.url.href)}).catch(() => {}); + try { await Bun.aws.credentials(); console.log("resolved?!"); } + catch (e) { console.log(e.code); } + process.exit(0); + `, + { + BUN_CONFIG_MAX_HTTP_REQUESTS: "1", + AWS_EC2_METADATA_DISABLED: undefined, + AWS_EC2_METADATA_SERVICE_ENDPOINT: hang.url.href, + AWS_METADATA_SERVICE_TIMEOUT: "0.2", + }, + ); + expect(stdout.trim()).toBe("ERR_AWS_MISSING_CREDENTIALS"); + expect(exitCode).toBe(0); + }); + + test("workers with their own env resolve their own credentials", async () => { + const { stdout, exitCode } = await run( + ` + const main = await Bun.aws.credentials(); + const worker = new Worker("data:text/javascript," + encodeURIComponent('self.postMessage((await Bun.aws.credentials()).accessKeyId)'), { + env: { ...process.env, AWS_ACCESS_KEY_ID: "AKIAWORKER", AWS_SECRET_ACCESS_KEY: "w" }, + }); + const fromWorker = await new Promise(resolve => (worker.onmessage = e => resolve(e.data))); + worker.terminate(); + console.log(main.accessKeyId, fromWorker, (await Bun.aws.credentials()).accessKeyId); + `, + { AWS_ACCESS_KEY_ID: "AKIAMAIN", AWS_SECRET_ACCESS_KEY: "m" }, + ); + expect(stdout.trim()).toBe("AKIAMAIN AKIAWORKER AKIAMAIN"); + expect(exitCode).toBe(0); + }); + + test("a hung credential endpoint does not hold up process exit", async () => { + using hung = Bun.serve({ port: 0, fetch: () => new Promise(() => {}) }); + const started = Date.now(); + const { stdout, exitCode } = await run( + ` + Bun.aws.credentials().then(() => console.log("resolved?"), () => console.log("rejected?")); + setTimeout(() => { console.log("exiting"); process.exit(0); }, 50); + `, + { AWS_CONTAINER_CREDENTIALS_FULL_URI: `http://127.0.0.1:${hung.port}/`, AWS_METADATA_SERVICE_TIMEOUT: "60" }, + ); + expect(stdout.trim()).toBe("exiting"); + expect(exitCode).toBe(0); + // Far below the 60s x 3 attempts the request would otherwise wait. + expect(Date.now() - started).toBeLessThan(20_000); + }); + + test.skipIf(isWindows)("a hung credential_process does not hold up worker termination", async () => { + const files = writeAwsFiles("aws-credproc-hung", { + config: `[default]\ncredential_process = /bin/sh -c "sleep 20"\n`, + }); + using _ = files.dir; + const started = Date.now(); + const { stdout, exitCode } = await run( + ` + const w = new Worker("data:text/javascript," + encodeURIComponent(\` + Bun.aws.credentials().catch(() => {}); + self.postMessage("started"); + \`)); + await new Promise(r => (w.onmessage = r)); + await w.terminate(); + console.log("terminated"); + `, + files.env, + ); + expect(stdout.trim()).toBe("terminated"); + expect(exitCode).toBe(0); + // The helper sleeps 20s; termination must not wait for it. + expect(Date.now() - started).toBeLessThan(15_000); + }); + + test("an unreachable IMDS is 'not configured', not an error", async () => { + // Port 9 (discard) on loopback refuses connections immediately. + const result = await creds({ + AWS_EC2_METADATA_DISABLED: undefined, + AWS_EC2_METADATA_SERVICE_ENDPOINT: "http://127.0.0.1:9", + AWS_METADATA_SERVICE_TIMEOUT: "2", + }); + expect(result.error.code).toBe("ERR_AWS_MISSING_CREDENTIALS"); + expect(result.error.message).toContain("EC2 instance metadata (http://127.0.0.1:9 is unreachable"); + }); + + test("results are cached per process; refresh: true re-resolves; expiring credentials refresh themselves", async () => { + using dir = tempDir("aws-cache", { token: "container-auth-token" }); + const env = { + AWS_CONTAINER_CREDENTIALS_FULL_URI: `http://127.0.0.1:${container.port}/cache-test`, + AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE: join(dir, "token"), + }; + const count = () => hits.container.filter(h => h.path === "/cache-test").length; + const before = count(); + const { stdout, exitCode } = await run( + ` + const a = await Bun.aws.credentials(); + const b = await Bun.aws.credentials(); + const [c, d] = await Promise.all([Bun.aws.credentials({ refresh: true }), Bun.aws.credentials()]); + console.log(a.accessKeyId, b.accessKeyId, c.accessKeyId, d.accessKeyId); + `, + env, + ); + expect(stdout.trim()).toBe("ASIACONTAINER ASIACONTAINER ASIACONTAINER ASIACONTAINER"); + expect(exitCode).toBe(0); + // first call + the refresh; the concurrent 4th call joins the refresh in flight + expect(count() - before).toBe(2); + }); + + test("short-lived credentials do not cause back-to-back refreshes", async () => { + // Credentials issued already inside the 5-minute refresh window: they are + // used as-is for a while rather than re-fetched on every request. + let n = 0; + using server = Bun.serve({ + port: 0, + fetch() { + return Response.json({ + AccessKeyId: "ASIASOON" + ++n, + SecretAccessKey: "s", + Token: "t", + Expiration: new Date(Date.now() + 60_000).toISOString(), + }); + }, + }); + const { stdout, exitCode } = await run( + ` + const a = await Bun.aws.credentials(); + const b = await Bun.aws.credentials(); + await Bun.aws.fetch(${JSON.stringify(s3.url.href)}, { service: "s3", region: "us-east-1" }); + console.log(a.accessKeyId, b.accessKeyId); + `, + { AWS_CONTAINER_CREDENTIALS_FULL_URI: `http://127.0.0.1:${server.port}/` }, + ); + expect(stdout.trim()).toBe("ASIASOON1 ASIASOON1"); + expect(n).toBe(1); + expect(exitCode).toBe(0); + }); + + test("credentials are refreshed in the background before they expire, without any call", async () => { + let n = 0; + let second!: () => void; + const refreshed = new Promise(r => (second = r)); + using server = Bun.serve({ + port: 0, + fetch() { + if (++n >= 2) second(); + return Response.json({ + AccessKeyId: "ASIATIMER" + n, + SecretAccessKey: "s", + Token: "t", + // 7s left: past the 5s expiry margin, so usable, and short enough + // that the refresh timer is armed for ~1s out. + Expiration: new Date(Date.now() + 7_000).toISOString(), + }); + }, + }); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `console.log((await Bun.aws.credentials()).accessKeyId); process.stdin.on("data", () => {}); // stay alive, idle`, + ], + env: { ...baseEnv, AWS_CONTAINER_CREDENTIALS_FULL_URI: `http://127.0.0.1:${server.port}/` } as any, + stdin: "pipe", + stdout: "pipe", + stderr: "inherit", + }); + // The second fetch comes from the timer, not from a call. + await Promise.race([ + refreshed, + proc.exited.then(code => Promise.reject(new Error(`child exited early (${code})`))), + ]); + proc.kill(); + expect(await proc.stdout.text()).toBe("ASIATIMER1\n"); + expect(n).toBeGreaterThanOrEqual(2); + }); + + test("a background refresh nobody is waiting for does not keep the process alive", async () => { + let n = 0; + let refreshing!: () => void; + const refreshSeen = new Promise(r => (refreshing = r)); + using server = Bun.serve({ + port: 0, + fetch() { + if (++n > 1) { + refreshing(); + return new Promise(() => {}); // the refresh hangs + } + return Response.json({ + AccessKeyId: "ASIAEXIT", + SecretAccessKey: "s", + Token: "t", + Expiration: new Date(Date.now() + 7_000).toISOString(), // refresh timer ~1s out + }); + }, + }); + const started = Date.now(); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + // Idle (stdin keeps it up) until told the refresh went out, then let go + // of stdin: nothing but the hung refresh is left, and that must not count. + `console.log((await Bun.aws.credentials()).accessKeyId); + process.stdin.once("data", () => { console.log("done"); process.stdin.destroy(); });`, + ], + env: { + ...baseEnv, + AWS_CONTAINER_CREDENTIALS_FULL_URI: `http://127.0.0.1:${server.port}/`, + AWS_METADATA_SERVICE_TIMEOUT: "30", + } as any, + stdin: "pipe", + stdout: "pipe", + stderr: "inherit", + }); + await Promise.race([refreshSeen, proc.exited]); + proc.stdin.write("go\n"); + await proc.stdin.flush(); + expect(await proc.stdout.text()).toBe("ASIAEXIT\ndone\n"); + expect(await proc.exited).toBe(0); + expect(n).toBe(2); + // Well short of the hung refresh's 30s deadline. + expect(Date.now() - started).toBeLessThan(15_000); + }); + + test("SSO profile without a cached token explains how to log in", async () => { + const files = writeAwsFiles("aws-sso", { + config: `[profile dev]\nsso_session = corp\nsso_account_id = 123456789012\nsso_role_name = Developer\nregion = us-east-1\n[sso-session corp]\nsso_start_url = https://corp.awsapps.com/start\nsso_region = us-east-1\n`, + }); + using _ = files.dir; + const result = await creds({ ...files.env, AWS_PROFILE: "dev" }); + expect(result.error.code).toBe("ERR_AWS_CREDENTIALS"); + expect(result.error.message).toContain("aws sso login --sso-session corp"); + }); + + test("SSO profile whose cached token exists but cannot be read says so", async () => { + const files = writeAwsFiles("aws-sso2", { + config: `[profile dev]\nsso_session = corp\nsso_account_id = 123456789012\nsso_role_name = Developer\nregion = us-east-1\n[sso-session corp]\nsso_start_url = https://corp.awsapps.com/start\nsso_region = us-east-1\n`, + }); + using _ = files.dir; + const key = new Bun.CryptoHasher("sha1").update("corp").digest("hex") + ".json"; + using home = tempDir("aws-sso-home", { ".aws": { sso: { cache: { [key]: { x: "" } } } } }); // a directory + const unreadable = await creds({ ...files.env, AWS_PROFILE: "dev", HOME: home, USERPROFILE: home }); + expect(unreadable.error.message).toContain( + `could not read the cached SSO token at ${join(home, ".aws", "sso", "cache", key)} (E`, + ); + expect(unreadable.error.message).not.toContain("aws sso login"); + }); + + test("argument validation", () => { + // @ts-expect-error + expect(() => Bun.aws.credentials("default")).toThrow("options object"); + // @ts-expect-error + expect(() => Bun.aws.credentials({ profile: 123 })).toThrow(); + }); +}); + +// ── S3 integration ───────────────────────────────────────────────────────── + +// Sequential: these assert on exact request counts against the shared mocks. +describe("S3Client with ambient credentials", () => { + const imdsEnv = () => ({ + AWS_EC2_METADATA_DISABLED: undefined, + AWS_EC2_METADATA_SERVICE_ENDPOINT: imds.url.href, + S3_ENDPOINT: s3.url.href, + S3_BUCKET: "bucket", + }); + + test("Bun.s3 / S3Client / fetch(s3://) sign with instance-metadata credentials", async () => { + const before = hits.s3.length; + const { stdout, stderr, exitCode } = await run( + ` + import { s3, S3Client } from "bun"; + const results = []; + results.push(await s3.file("a.txt").text()); + results.push(await new S3Client().file("b.txt").text()); + results.push(await (await fetch("s3://bucket/c.txt")).text()); + results.push(String((await s3.file("d.txt").stat()).size)); + await s3.file("e.txt").write("hello"); + results.push(await new Response(s3.file("f.txt").stream()).text()); + // presign is synchronous and uses the (now cached) credentials + const url = new URL(s3.file("g.txt").presign({ expiresIn: 60 })); + results.push(url.searchParams.get("X-Amz-Credential").split("/")[0], url.searchParams.has("X-Amz-Security-Token")); + console.log(JSON.stringify(results)); + `, + imdsEnv(), + ); + expect(stderr).toBe(""); + expect(JSON.parse(stdout.trim())).toEqual(["hello", "hello", "hello", "5", "hello", "ASIAIMDS", true]); + expect(exitCode).toBe(0); + const mine = hits.s3.slice(before); + expect(mine.length).toBe(6); + for (const hit of mine) { + expect(hit.headers.authorization).toStartWith("AWS4-HMAC-SHA256 Credential=ASIAIMDS/"); + expect(hit.headers["x-amz-security-token"]).toBe("imds-token"); + } + }); + + test("many concurrent first requests share one credential resolution", async () => { + const before = hits.imds.filter(h => h.path === "/latest/api/token").length; + const { stdout, exitCode } = await run( + ` + import { s3 } from "bun"; + const texts = await Promise.all(Array.from({ length: 20 }, (_, i) => s3.file("k" + i).text())); + console.log(texts.every(t => t === "hello"), texts.length); + `, + imdsEnv(), + ); + expect(stdout.trim()).toBe("true 20"); + expect(exitCode).toBe(0); + expect(hits.imds.filter(h => h.path === "/latest/api/token").length - before).toBe(1); + }); + + test("explicit keys and env keys still win; `profile` selects a profile", async () => { + const files = writeAwsFiles("aws-s3-profile", { + credentials: `[ci]\naws_access_key_id = AKIAPROFILECI\naws_secret_access_key = ci-secret\n`, + }); + using _ = files.dir; + const before = hits.s3.length; + const { stdout, exitCode } = await run( + ` + import { s3, S3Client } from "bun"; + await new S3Client({ accessKeyId: "AKIAEXPLICIT", secretAccessKey: "x" }).file("a").text(); + await new S3Client({ profile: "ci" }).file("b").text(); + await s3.file("c", { profile: "ci" }).text(); + console.log("ok"); + `, + { ...imdsEnv(), ...files.env }, + ); + expect(stdout.trim()).toBe("ok"); + expect(exitCode).toBe(0); + const akids = hits.s3.slice(before).map(h => h.headers.authorization.match(/Credential=([^/]+)\//)![1]); + expect(akids).toEqual(["AKIAEXPLICIT", "AKIAPROFILECI", "AKIAPROFILECI"]); + }); + + test("synchronous presign: uses env/profile credentials on the spot, but never waits on the network", async () => { + const files = writeAwsFiles("aws-s3-presign-sync", { + credentials: `[default]\naws_access_key_id = AKIASTATICPROFILE\naws_secret_access_key = p\n`, + }); + using _ = files.dir; + // Static profile keys need no I/O, so the very first synchronous call works. + const fromProfile = await run( + ` + import { s3 } from "bun"; + console.log(new URL(s3.file("a").presign()).searchParams.get("X-Amz-Credential").split("/")[0]); + `, + { S3_ENDPOINT: s3.url.href, S3_BUCKET: "bucket", ...files.env }, + ); + expect(fromProfile.stdout.trim()).toBe("AKIASTATICPROFILE"); + expect(fromProfile.exitCode).toBe(0); + + // Instance-metadata credentials need a round-trip: the first synchronous + // call says so (and starts resolving in the background); once anything + // asynchronous has resolved them, synchronous calls work. + const fromImds = await run( + ` + import { s3 } from "bun"; + try { s3.file("a").presign(); console.log("unexpected"); } catch (e) { console.log(e.code, /have not been resolved yet.*await Bun\.aws\.credentials\(\)/.test(e.message)); } + await Bun.aws.credentials(); + console.log(new URL(s3.file("a").presign()).searchParams.get("X-Amz-Credential").split("/")[0]); + `, + imdsEnv(), + ); + expect(fromImds.stdout.trim().split("\n")).toEqual(["ERR_S3_MISSING_CREDENTIALS true", "ASIAIMDS"]); + expect(fromImds.exitCode).toBe(0); + + // Once the chain has actually failed, the synchronous error says why + // rather than "not resolved yet". + using broken = Bun.serve({ port: 0, fetch: () => new Response("nope", { status: 500 }) }); + const afterFailure = await run( + ` + import { s3 } from "bun"; + try { s3.file("a").presign(); } catch (e) { console.log(/have not been resolved yet/.test(e.message)); } + await Bun.aws.credentials().catch(e => console.log(e.code)); + try { s3.file("a").presign(); } catch (e) { console.log(e.code, /answered HTTP 500/.test(e.message)); } + `, + { ...imdsEnv(), AWS_EC2_METADATA_SERVICE_ENDPOINT: broken.url.href }, + ); + expect(afterFailure.stdout.trim().split("\n")).toEqual(["true", "ERR_AWS_CREDENTIALS", "ERR_AWS_CREDENTIALS true"]); + expect(afterFailure.exitCode).toBe(0); + }); + + test("resolution does not block the JavaScript thread", async () => { + let release!: () => void; + const gate = new Promise(r => (release = r)); + using slow = Bun.serve({ + port: 0, + async fetch(req) { + const path = new URL(req.url).pathname; + if (req.method === "PUT") return new Response("tok"); + if (path.endsWith("/security-credentials/")) { + await gate; // held until the child proves its event loop is still turning + return new Response("role"); + } + return Response.json({ + Code: "Success", + AccessKeyId: "ASIASLOW", + SecretAccessKey: "s", + Token: "t", + Expiration: "2099-01-01T00:00:00Z", + }); + }, + }); + using ping = Bun.serve({ + port: 0, + fetch() { + release(); + return new Response("pong"); + }, + }); + const { stdout, exitCode } = await run( + ` + const pending = Bun.aws.credentials(); + // While IMDS is stalling, unrelated work keeps running: this fetch is + // what lets IMDS answer at all. + console.log(await (await fetch(process.env.PING_URL)).text()); + console.log((await pending).accessKeyId); + `, + { + AWS_EC2_METADATA_DISABLED: undefined, + AWS_EC2_METADATA_SERVICE_ENDPOINT: slow.url.href, + PING_URL: ping.url.href, + }, + ); + expect(stdout.trim().split("\n")).toEqual(["pong", "ASIASLOW"]); + expect(exitCode).toBe(0); + }); + + test("no ambient credentials anywhere → ERR_S3_MISSING_CREDENTIALS with the chain's explanation", async () => { + const { stdout, exitCode } = await run( + ` + import { s3 } from "bun"; + try { await s3.file("a").text(); } catch (e) { console.log(e.code, e.message.includes("AWS_EC2_METADATA_DISABLED")); } + try { s3.file("a").presign(); } catch (e) { console.log(e.code, e.message.includes("AWS_EC2_METADATA_DISABLED")); } + const r = await fetch("s3://bucket/a"); // fetch resolves per WHATWG: rejection, not throw + `.replace("const r = await", "try { await") + `} catch (e) { console.log(e.code); }`, + { S3_ENDPOINT: s3.url.href, S3_BUCKET: "bucket" }, + ); + expect(stdout.trim().split("\n")).toEqual([ + "ERR_S3_MISSING_CREDENTIALS true", + "ERR_S3_MISSING_CREDENTIALS true", + "ERR_S3_MISSING_CREDENTIALS", + ]); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/aws/aws-eventstream.test.ts b/test/js/bun/aws/aws-eventstream.test.ts new file mode 100644 index 000000000000..46c30c3ea715 --- /dev/null +++ b/test/js/bun/aws/aws-eventstream.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, test } from "bun:test"; + +// Frames produced by @smithy/eventstream-codec (the AWS SDK for JavaScript's +// implementation) for the messages described next to them. +const reference = { + // {":message-type":"event",":event-type":"chunk",":content-type":"application/json"} + Bedrock-style {"bytes": base64(json)} + bedrockChunk: + "AAAAswAAAEvTSzW1DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcABWNodW5rDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29ueyJieXRlcyI6ImV5SjBlWEJsSWpvaVkyOXVkR1Z1ZEY5aWJHOWphMTlrWld4MFlTSXNJbVJsYkhSaElqcDdJblJsZUhRaU9pSklaV3hzYnlKOWZRPT0ifQQ557A=", + // every header value type, empty payload + allHeaderTypes: + "AAAAkwAAAIOHNVAzCWJvb2wtdHJ1ZQAKYm9vbC1mYWxzZQEGYS1ieXRlAvsHYS1zaG9ydAP+1AZhbi1pbnQEAAHiQAZhLWxvbmcF/////////4UDYmluBgAEAQID/gNzdHIHAApow6lsbG8g4pyTAnRzCAAAAYvP5Wh7AmlkCQECAwQFBgcICQoLDA0ODxArAf87", + // {":message-type":"exception",":exception-type":"ValidationException"} + {"message":"Malformed input request"} + exception: + "AAAAlgAAAGEB0VwXDTptZXNzYWdlLXR5cGUHAAlleGNlcHRpb24POmV4Y2VwdGlvbi10eXBlBwATVmFsaWRhdGlvbkV4Y2VwdGlvbg06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbnsibWVzc2FnZSI6Ik1hbGZvcm1lZCBpbnB1dCByZXF1ZXN0In24Hegc", + // {":message-type":"error",":error-code":"InternalError",":error-message":"boom"} + error: + "AAAAWAAAAEgVRpLBDTptZXNzYWdlLXR5cGUHAAVlcnJvcgs6ZXJyb3ItY29kZQcADUludGVybmFsRXJyb3IOOmVycm9yLW1lc3NhZ2UHAARib29tlhy0Vg==", +}; +const bytes = (b64: string) => new Uint8Array(Buffer.from(b64, "base64")); + +/** Minimal encoder (string headers only) for building test streams. */ +function frame(headers: Record, payload: string | Uint8Array): Uint8Array { + const enc = new TextEncoder(); + const parts: Uint8Array[] = []; + for (const [k, v] of Object.entries(headers)) { + const kb = enc.encode(k), + vb = enc.encode(v); + parts.push(new Uint8Array([kb.length]), kb, new Uint8Array([7, vb.length >> 8, vb.length & 255]), vb); + } + const hbuf = Buffer.concat(parts); + const body = typeof payload === "string" ? enc.encode(payload) : payload; + const total = 16 + hbuf.length + body.length; + const out = Buffer.alloc(total); + out.writeUInt32BE(total, 0); + out.writeUInt32BE(hbuf.length, 4); + out.writeUInt32BE(Bun.hash.crc32(out.subarray(0, 8)), 8); + hbuf.copy(out, 12); + Buffer.from(body).copy(out, 12 + hbuf.length); + out.writeUInt32BE(Bun.hash.crc32(out.subarray(0, total - 4)), total - 4); + return new Uint8Array(out); +} + +async function collect(source: unknown) { + const out: any[] = []; + for await (const m of Bun.aws.eventStream(source as any)) out.push(m); + return out; +} + +describe("Bun.aws.eventStream", () => { + test("decodes SDK-produced frames: Bedrock chunk, every header type", async () => { + const [chunk] = await collect(bytes(reference.bedrockChunk)); + expect(chunk.type).toBe("event"); + expect(chunk.event).toBe("chunk"); + expect(chunk.contentType).toBe("application/json"); + expect(JSON.parse(atob((chunk.json() as any).bytes))).toEqual({ + type: "content_block_delta", + delta: { text: "Hello" }, + }); + + const [typed] = await collect(bytes(reference.allHeaderTypes)); + expect(typed.payload.byteLength).toBe(0); + expect(typed.type).toBeUndefined(); + expect(typed.headers).toEqual({ + "bool-true": true, + "bool-false": false, + "a-byte": -5, + "a-short": -300, + "an-int": 123456, + "a-long": -123n, + bin: new Uint8Array([1, 2, 3, 254]), + str: "héllo ✓", + ts: new Date("2023-11-14T22:13:20.123Z"), + id: "01020304-0506-0708-090a-0b0c0d0e0f10", + }); + }); + + test("exception and error frames throw like the SDKs", async () => { + let err: any; + try { + await collect(bytes(reference.exception)); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("ValidationException"); + expect(err.code).toBe("ERR_AWS_EVENT_STREAM_EXCEPTION"); + expect(err.message).toBe("Malformed input request"); + expect(err.headers[":content-type"]).toBe("application/json"); + expect(JSON.parse(err.body)).toEqual({ message: "Malformed input request" }); // whole payload, for extra members + + err = undefined; + try { + await collect(bytes(reference.error)); + } catch (e) { + err = e; + } + expect(err.name).toBe("InternalError"); + expect(err.code).toBe("ERR_AWS_EVENT_STREAM_ERROR"); + expect(err.message).toBe("boom"); + + // Events before the exception are still delivered. + const seen: string[] = []; + err = undefined; + try { + const both = Buffer.concat([ + frame({ ":message-type": "event", ":event-type": "a" }, "1"), + bytes(reference.exception), + ]); + for await (const m of Bun.aws.eventStream(both)) seen.push(m.event!); + } catch (e) { + err = e; + } + expect(seen).toEqual(["a"]); + expect(err?.name).toBe("ValidationException"); + }); + + test("frames split across arbitrary chunk boundaries (ReadableStream, async iterable, Response)", async () => { + const messages = Array.from({ length: 20 }, (_, i) => + frame( + { ":message-type": "event", ":event-type": "n" }, + JSON.stringify({ i, pad: Buffer.alloc(i * 37, "x").toString() }), + ), + ); + const all = Buffer.concat(messages); + for (const step of [1, 3, 11, 12, 13, 64, all.length]) { + const stream = new ReadableStream({ + start(controller) { + for (let i = 0; i < all.length; i += step) controller.enqueue(all.subarray(i, i + step)); + controller.close(); + }, + }); + const got = await collect(stream); + expect(got.map(m => (m.json() as any).i)).toEqual(messages.map((_, i) => i)); + } + async function* gen() { + yield all.subarray(0, 7); + yield all.subarray(7, 700).buffer.slice(all.byteOffset + 7, all.byteOffset + 700); // an ArrayBuffer chunk + yield all.subarray(700); + } + expect((await collect(gen())).length).toBe(20); + expect((await collect(new Response(all))).length).toBe(20); + expect((await collect(new Blob([all]))).length).toBe(20); + expect(await collect(new Uint8Array(0))).toEqual([]); + }); + + test("corruption and truncation are errors, not silent data loss", async () => { + const good = frame({ ":message-type": "event" }, "payload"); + const cases: [string, Uint8Array, RegExp][] = [ + ["payload bit flip", good.map((b, i) => (i === 40 ? b ^ 1 : b)), /message checksum/], + ["prelude bit flip", good.map((b, i) => (i === 5 ? b ^ 1 : b)), /prelude checksum/], + ["truncated", good.subarray(0, good.length - 1), /middle of a message/], + ["trailing garbage", new Uint8Array([...good, 1, 2, 3]), /middle of a message/], + [ + "invalid UTF-8 header name", + (() => { + const f = Buffer.from(frame({ abc: "v" }, "")); + f[13] = 0xff; // first byte of the header name + f.writeUInt32BE(Bun.hash.crc32(f.subarray(0, f.length - 4)), f.length - 4); + return new Uint8Array(f); + })(), + /not valid UTF-8/, + ], + ]; + for (const [name, data, pattern] of cases) { + let err: any; + try { + await collect(data); + } catch (e) { + err = e; + } + expect(err?.code, name).toBe("ERR_AWS_EVENT_STREAM"); + expect(err.message, name).toMatch(pattern); + } + // absurd total_length is rejected up front rather than buffered forever + const huge = Buffer.from(good); + huge.writeUInt32BE(0x7fffffff, 0); + huge.writeUInt32BE(Bun.hash.crc32(huge.subarray(0, 8)), 8); + await expect(collect(new Uint8Array(huge))).rejects.toThrow(/bad frame lengths/); + // not a byte source: rejected at the call, not on first read + expect(() => Bun.aws.eventStream(42 as any)).toThrow(/"source" argument must be/); + // messages decoded before a bad frame in the same chunk are still delivered + const seen: string[] = []; + let err: any; + try { + const evt = frame({ ":message-type": "event", ":event-type": "ok" }, "1"); + for await (const m of Bun.aws.eventStream(Buffer.concat([evt, evt, good.map((b, i) => (i === 40 ? b ^ 1 : b))]))) + seen.push(m.event!); + } catch (e) { + err = e; + } + expect(seen).toEqual(["ok", "ok"]); + expect(err?.code).toBe("ERR_AWS_EVENT_STREAM"); + }); + + test("payloads are copies the caller owns; a producer may reuse its buffer between chunks", async () => { + const one = Buffer.from(frame({ ":message-type": "event", bin: "x" }, "hello")); + const [m] = await collect(one); + one.fill(0); + expect(m.text()).toBe("hello"); + expect(m.payload.buffer).not.toBe(one.buffer); + // always a plain Uint8Array, wherever chunk boundaries fell + expect(m.payload.constructor).toBe(Uint8Array); + // non-configurable like the class's other methods + expect(Object.getOwnPropertyDescriptor(Bun.AWSClient.prototype, "eventStream")?.configurable).toBe(false); + + const all = Buffer.concat( + Array.from({ length: 6 }, (_, i) => + frame({ ":message-type": "event", ":event-type": "e" + i }, Buffer.alloc(50 + i, "p").toString()), + ), + ); + async function* reusing(size: number) { + const scratch = new Uint8Array(size); + for (let i = 0; i < all.length; i += size) { + const n = Math.min(size, all.length - i); + scratch.set(all.subarray(i, i + n)); + yield scratch.subarray(0, n); + scratch.fill(0xee); // clobber what we handed out + } + } + for (const size of [7, 30, 100, 250]) { + expect((await collect(reusing(size))).map(m => m.event)).toEqual(["e0", "e1", "e2", "e3", "e4", "e5"]); + } + + // One large frame dribbled in small chunks is reassembled without + // re-copying what was already buffered (this would take minutes if quadratic). + const big = frame({ ":message-type": "event" }, new Uint8Array(Buffer.alloc(4 * 1024 * 1024, "z"))); + async function* dribble() { + for (let i = 0; i < big.length; i += 1024) yield big.subarray(i, i + 1024); + } + const [whole] = await collect(dribble()); + expect(whole.payload.length).toBe(4 * 1024 * 1024); + }); + + test("a chunk is fully consumed before yielding, so ring-buffer producers are safe", async () => { + // Two frames in one buffer that is clobbered while the consumer holds + // the first message: the second must already have been copied out. + const scratch = new Uint8Array( + Buffer.concat([ + frame({ ":message-type": "event", ":event-type": "one" }, "1"), + frame({ ":message-type": "event", ":event-type": "two" }, "2"), + ]), + ); + const source = { + async *[Symbol.asyncIterator]() { + yield scratch; + }, + }; + const seen: string[] = []; + for await (const m of Bun.aws.eventStream(source)) { + seen.push(m.event!); + scratch.fill(0xee); + } + expect(seen).toEqual(["one", "two"]); + // payload text is decoded leniently (split multi-byte sequences happen in byte streams) + const [m] = await collect(frame({ ":message-type": "event" }, new Uint8Array([0x68, 0xff, 0x69]))); + expect(m.text()).toBe("h\ufffdi"); + }); + + test("a non-2xx Response is reported with its status and message instead of being framed", async () => { + using server = Bun.serve({ + port: 0, + fetch: () => + Response.json( + { message: "The security token included in the request is expired" }, + { + status: 403, + headers: { + "x-amzn-errortype": "ExpiredTokenException:http://internal.amazon.com/coral/com.amazon.coral.service/", + }, + }, + ), + }); + let err: any; + try { + await collect(await fetch(server.url)); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_AWS_EVENT_STREAM_RESPONSE"); + expect(err.name).toBe("ExpiredTokenException"); + expect(err.status).toBe(403); + expect(err.message).toBe("HTTP 403: The security token included in the request is expired"); + expect(JSON.parse(err.body).message).toBe("The security token included in the request is expired"); + expect(err.headers.get("x-amzn-errortype")).toStartWith("ExpiredTokenException"); + + // an already-consumed body is an error, not an empty stream + const used = await fetch(server.url); + await used.text(); + await expect(collect(new Response("x", { status: 200 }))).rejects.toThrow(/middle of a message/); + await expect(collect(used)).rejects.toThrow(/HTTP 403/); // !ok is checked first + const ok = new Response(frame({ ":message-type": "event" }, "x")); + await ok.arrayBuffer(); + await expect(collect(ok)).rejects.toThrow(/already consumed/); + }); + + test("end to end: signed request to a streaming endpoint, decoded as it arrives", async () => { + let authorization: string | null = null; + let release!: () => void; + const gate = new Promise(r => (release = r)); + using server = Bun.serve({ + port: 0, + async fetch(req) { + authorization = req.headers.get("authorization"); + return new Response( + new ReadableStream({ + async start(controller) { + controller.enqueue( + frame({ ":message-type": "event", ":event-type": "messageStart" }, `{"role":"assistant"}`), + ); + await gate; // the client must see the first event before the rest is sent + for (const word of ["Hello", ", ", "world"]) { + controller.enqueue( + frame( + { ":message-type": "event", ":event-type": "contentBlockDelta" }, + JSON.stringify({ delta: { text: word } }), + ), + ); + } + controller.enqueue( + frame({ ":message-type": "event", ":event-type": "messageStop" }, `{"stopReason":"end_turn"}`), + ); + controller.close(); + }, + }), + { headers: { "content-type": "application/vnd.amazon.eventstream" } }, + ); + }, + }); + const res = await Bun.aws.fetch(`${server.url}model/x/converse-stream`, { + method: "POST", + body: "{}", + accessKeyId: "AKID", + secretAccessKey: "secret", + service: "bedrock", + region: "us-east-1", + }); + let text = ""; + const events: string[] = []; + for await (const m of Bun.aws.eventStream(res)) { + events.push(m.event!); + if (m.event === "messageStart") release(); + if (m.event === "contentBlockDelta") text += (m.json() as any).delta.text; + } + expect(events).toEqual([ + "messageStart", + "contentBlockDelta", + "contentBlockDelta", + "contentBlockDelta", + "messageStop", + ]); + expect(text).toBe("Hello, world"); + expect(authorization).toStartWith("AWS4-HMAC-SHA256 Credential=AKID/"); + }); +}); diff --git a/test/js/bun/aws/aws-sigv4.test.ts b/test/js/bun/aws/aws-sigv4.test.ts new file mode 100644 index 000000000000..d1f6601ea09d --- /dev/null +++ b/test/js/bun/aws/aws-sigv4.test.ts @@ -0,0 +1,553 @@ +import type { Server } from "bun"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { tempDir, tls } from "harness"; +import { join } from "path"; +import { referencePresignCheck, referenceSign, sha256Hex } from "./sigv4-reference"; + +// In-process tests: every request carries explicit credentials so nothing +// ambient (env, ~/.aws, instance metadata) is consulted. +const accessKeyId = "AKIDEXAMPLE"; +const secretAccessKey = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"; +const datetime = "20150830T123600Z"; + +let echo: Server; +type Echo = { method: string; url: string; headers: Record; body: string }; + +beforeAll(() => { + echo = Bun.serve({ + port: 0, + async fetch(req) { + return Response.json({ + method: req.method, + url: req.url, + headers: Object.fromEntries(req.headers), + body: await req.text(), + } satisfies Echo); + }, + }); +}); +afterAll(() => echo?.stop(true)); + +async function signedFetch(path: string, { aws, ...init }: RequestInit & { aws: any }): Promise { + const res = await Bun.aws.fetch(new URL(path, echo.url), { ...init, ...aws }); + expect(res.status).toBe(200); + return res.json(); +} + +describe("Bun.aws.fetch", () => { + test("matches the AWS SigV4 test-suite vector (get-vanilla)", async () => { + // Host must be example.amazonaws.com for the published signature, so + // sign against that Host header while sending to the local echo server. + const hit = await signedFetch("/", { + headers: { Host: "example.amazonaws.com" }, + aws: { accessKeyId, secretAccessKey, service: "service", region: "us-east-1", signingDate: datetime }, + }); + expect(hit.headers.authorization).toBe( + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31", + ); + expect(hit.headers["x-amz-date"]).toBe(datetime); + // Only S3 wants x-amz-content-sha256 on the wire (and any x-amz-* header + // that is sent has to be signed, which would break the published vector). + expect(hit.headers["x-amz-content-sha256"]).toBeUndefined(); + }); + + test("POST with body, query string, extra signed headers and a session token", async () => { + const body = JSON.stringify({ TableName: "t", Key: { id: { S: "1" } } }); + const headers = { + "Content-Type": "application/x-amz-json-1.0", + "X-Amz-Target": "DynamoDB_20120810.GetItem", + "X-Custom": " spaced value ", + }; + const path = "/some/path/../other?b=2&a=1&a=0&empty=&%C3%A9=%20x"; + const hit = await signedFetch(path, { + method: "POST", + body, + headers, + aws: { + accessKeyId, + secretAccessKey, + sessionToken: "session/token+with=chars", + service: "dynamodb", + region: "us-west-2", + signingDate: datetime, + }, + }); + const expected = referenceSign({ + method: "POST", + url: new URL(path, echo.url).href, + headers, + body, + service: "dynamodb", + region: "us-west-2", + accessKeyId, + secretAccessKey, + sessionToken: "session/token+with=chars", + datetime, + }); + expect(hit.headers.authorization).toBe(expected.authorization); + expect(hit.headers["x-amz-security-token"]).toBe("session/token+with=chars"); + expect(hit.body).toBe(body); + }); + + test("s3 semantics: single-encoded path, x-amz-content-sha256 signed, unsignedPayload", async () => { + const path = "/my bucket/key with spaces/ünïcode/(parens)!.txt?versionId=abc"; + for (const unsignedPayload of [false, true]) { + const hit = await signedFetch(path, { + method: "PUT", + body: "payload", + aws: { + accessKeyId, + secretAccessKey, + service: "s3", + region: "eu-west-1", + signingDate: datetime, + unsignedPayload, + }, + }); + const expected = referenceSign({ + method: "PUT", + url: new URL(path, echo.url).href, + body: "payload", + service: "s3", + region: "eu-west-1", + accessKeyId, + secretAccessKey, + datetime, + unsignedPayload, + }); + expect(hit.headers.authorization).toBe(expected.authorization); + expect(hit.headers["x-amz-content-sha256"]).toBe(unsignedPayload ? "UNSIGNED-PAYLOAD" : sha256Hex("payload")); + } + }); + + test("service and region are inferred from *.amazonaws.com hostnames", async () => { + const cases: [string, string, string][] = [ + ["sqs.us-east-2.amazonaws.com", "sqs", "us-east-2"], + ["my-bucket.s3.ap-southeast-1.amazonaws.com", "s3", "ap-southeast-1"], + ["iam.amazonaws.com", "iam", "us-east-1"], + ["bedrock-runtime.eu-central-1.amazonaws.com", "bedrock", "eu-central-1"], // signing name differs from the host label + ["abc123.execute-api.us-west-1.amazonaws.com", "execute-api", "us-west-1"], + ["xyz.lambda-url.eu-west-1.on.aws", "lambda", "eu-west-1"], + ]; + for (const [host, service, region] of cases) { + const hit = await signedFetch("/", { + headers: { Host: host }, + aws: { accessKeyId, secretAccessKey, signingDate: datetime }, + }); + expect(hit.headers.authorization).toContain(`/${region}/${service}/aws4_request`); + } + }); + + test("errors: cannot infer, streaming body, reserved headers, half credentials", async () => { + const base = { accessKeyId, secretAccessKey }; + await expect(Bun.aws.fetch(echo.url, base)).rejects.toThrow(/cannot tell which AWS service/); + await expect(Bun.aws.fetch(echo.url, { ...base, service: "sqs" })).rejects.toThrow(/cannot tell which AWS region/); + await expect( + Bun.aws.fetch(echo.url, { + method: "POST", + body: new ReadableStream({ + start(c) { + c.enqueue(new TextEncoder().encode("x")); + c.close(); + }, + }), + ...base, + service: "sqs", + region: "us-east-1", + }), + ).rejects.toThrow(/streaming request body cannot be SigV4-signed/); + await expect( + Bun.aws.fetch(echo.url, { + headers: { Authorization: "Bearer x" }, + ...base, + service: "sqs", + region: "us-east-1", + }), + ).rejects.toThrow(/"Authorization" header is generated by request signing/i); + await expect(Bun.aws.fetch(echo.url, { accessKeyId: "x", service: "sqs", region: "us-east-1" })).rejects.toThrow( + /must be given together/, + ); + await expect(Bun.aws.fetch(echo.url, { ...base, service: "bad service!" })).rejects.toThrow( + /not a valid AWS service/, + ); + // signingDate: a Date, epoch ms, or an x-amz-date string; nothing else, nothing x-amz-date can't spell + const s3 = { ...base, service: "s3", region: "us-east-1" }; + for (const signingDate of [ + {}, + true, + -1, + 1e21, + NaN, + Infinity, + -Infinity, + new Date(NaN), + new Date("+010000-01-01T00:00:00Z"), + "20250101T000000\n", + "20250230T000000Z", + "20250101T000000z", + "nope", + ]) { + expect(() => Bun.aws.presign(echo.url, { ...s3, signingDate: signingDate as any })).toThrow(/signingDate/); + } + expect(await Bun.aws.presign(echo.url, { ...s3, signingDate: Date.UTC(2031, 0, 2, 3, 4, 5) })).toContain( + "X-Amz-Date=20310102T030405Z", + ); + expect(await Bun.aws.presign(echo.url, { ...s3, signingDate: 0 })).toContain("X-Amz-Date=19700101T000000Z"); + expect(await Bun.aws.presign(echo.url, { ...s3, signingDate: new Date(0) })).toContain( + "X-Amz-Date=19700101T000000Z", + ); + }); + + test("streaming bodies to s3 are sent with UNSIGNED-PAYLOAD", async () => { + const hit = await signedFetch("/bucket/key", { + method: "PUT", + body: new ReadableStream({ + start(c) { + c.enqueue(new TextEncoder().encode("streamed")); + c.close(); + }, + }), + aws: { accessKeyId, secretAccessKey, service: "s3", region: "us-east-1", signingDate: datetime }, + }); + expect(hit.body).toBe("streamed"); + expect(hit.headers["x-amz-content-sha256"]).toBe("UNSIGNED-PAYLOAD"); + const expected = referenceSign({ + method: "PUT", + url: new URL("/bucket/key", echo.url).href, + service: "s3", + region: "us-east-1", + accessKeyId, + secretAccessKey, + datetime, + unsignedPayload: true, + }); + expect(hit.headers.authorization).toBe(expected.authorization); + }); + + test("signQuery puts the signature in the URL and leaves headers alone", async () => { + const hit = await signedFetch("/queue/url?Action=SendMessage&MessageBody=hi%20there", { + aws: { + accessKeyId, + secretAccessKey, + sessionToken: "tok", + service: "sqs", + region: "us-east-1", + signQuery: true, + expiresIn: 90, + signingDate: datetime, + }, + }); + expect(hit.headers.authorization).toBeUndefined(); + const url = new URL(hit.url); + expect(url.searchParams.get("Action")).toBe("SendMessage"); + expect(url.searchParams.get("MessageBody")).toBe("hi there"); + expect(url.searchParams.get("X-Amz-Algorithm")).toBe("AWS4-HMAC-SHA256"); + expect(url.searchParams.get("X-Amz-Credential")).toBe("AKIDEXAMPLE/20150830/us-east-1/sqs/aws4_request"); + expect(url.searchParams.get("X-Amz-Date")).toBe(datetime); + expect(url.searchParams.get("X-Amz-Expires")).toBe("90"); + expect(url.searchParams.get("X-Amz-SignedHeaders")).toBe("host"); + expect(url.searchParams.get("X-Amz-Security-Token")).toBe("tok"); + const { expected, actual } = referencePresignCheck(hit.url, { + service: "sqs", + region: "us-east-1", + secretAccessKey, + }); + expect(actual).toBe(expected); + }); + + test("one-character path segments survive (canonical URI must not collapse to /)", async () => { + for (const path of ["/a", "/a/", "/a/b", "/ab"]) { + const hit = await signedFetch(path, { + aws: { accessKeyId, secretAccessKey, service: "s3", region: "us-east-1", signingDate: datetime }, + }); + expect(new URL(hit.url).pathname).toBe(path); + expect(hit.headers.authorization).toBe( + referenceSign({ + method: "GET", + url: new URL(path, echo.url).href, + service: "s3", + region: "us-east-1", + accessKeyId, + secretAccessKey, + datetime, + unsignedPayload: false, + }).authorization, + ); + } + expect( + new URL(await Bun.aws.presign("https://bkt.s3.amazonaws.com/a", { accessKeyId, secretAccessKey })).pathname, + ).toBe("/a"); + }); + + test("s3:// URLs are rejected (use fetch's s3 option / Bun.s3)", async () => { + await expect(Bun.aws.fetch("s3://bucket/key", { accessKeyId, secretAccessKey })).rejects.toThrow(/s3:\/\/ URLs/); + }); + + test("relative URLs go to the service's standard regional endpoint", async () => { + // Route the request over a unix socket so we can see which Host was built + // without touching the network. + using dir = tempDir("aws-rel", {}); + const unix = join(dir, "s.sock"); + using server = Bun.serve({ + unix, + tls, + fetch: req => + Response.json({ host: req.headers.get("host"), path: new URL(req.url).pathname + new URL(req.url).search }), + }); + const via = { accessKeyId, secretAccessKey, unix, tls: { rejectUnauthorized: false } } as const; + const seen = async (path: string, opts: object) => (await Bun.aws.fetch(path, { ...via, ...opts })).json(); + expect(await seen("/?Action=ListQueues", { service: "sqs", region: "us-west-2" })).toEqual({ + host: "sqs.us-west-2.amazonaws.com", + path: "/?Action=ListQueues", + }); + expect((await seen("/", { service: "dynamodb", region: "cn-north-1" })).host).toBe( + "dynamodb.cn-north-1.amazonaws.com.cn", + ); + expect((await seen("/", { service: "iam", region: "eu-west-1" })).host).toBe("iam.amazonaws.com"); + expect((await seen("/v2/email", { service: "ses", region: "us-east-1" })).host).toBe( + "email.us-east-1.amazonaws.com", + ); + await expect(Bun.aws.fetch("/?Action=ListQueues", { accessKeyId, secretAccessKey })).rejects.toThrow( + /needs `service`/, + ); + await expect(Bun.aws.fetch("/", { accessKeyId, secretAccessKey, service: "sqs" })).rejects.toThrow( + /pass `region` or set AWS_REGION/, + ); + await expect( + Bun.aws.fetch("/", { accessKeyId, secretAccessKey, service: "lambda", region: "us-east-1" }), + ).rejects.toThrow(/per-resource/); + }); + + test("a large file body is hashed, not sent via sendfile", async () => { + using dir = tempDir("aws-sendfile", { "big.bin": Buffer.alloc(256 * 1024, "x").toString() }); + const hit = await signedFetch("/upload", { + method: "PUT", + body: Bun.file(join(dir, "big.bin")), + aws: { accessKeyId, secretAccessKey, service: "execute-api", region: "us-east-1", signingDate: datetime }, + }); + expect(hit.body.length).toBe(256 * 1024); + expect(hit.headers.authorization).toBe( + referenceSign({ + method: "PUT", + url: new URL("/upload", echo.url).href, + headers: { "content-type": hit.headers["content-type"] }, + body: Buffer.alloc(256 * 1024, "x").toString(), + service: "execute-api", + region: "us-east-1", + accessKeyId, + secretAccessKey, + datetime, + }).authorization, + ); + }); + + test("a pre-aborted signal rejects immediately, before any credential lookup", async () => { + const ac = new AbortController(); + ac.abort(); + await expect(Bun.aws.fetch(echo.url, { signal: ac.signal })).rejects.toThrow(/aborted/i); + // …also when the region (relative URL) would have to come from the credential chain, + // and an invalid signal is a TypeError rather than a reason to go looking. + await expect(Bun.aws.fetch("/x", { service: "dynamodb", signal: ac.signal })).rejects.toThrow(/aborted/i); + await expect(Bun.aws.fetch("/x", { service: "dynamodb", signal: 42 as any })).rejects.toThrow(/AbortSignal/); + await expect(Bun.aws.fetch(echo.url, { signal: 42 as any })).rejects.toThrow(/AbortSignal/); + // init.signal is validated even when the Request already carries an aborted one + await expect(Bun.aws.fetch(new Request(echo.url, { signal: ac.signal }), { signal: 42 as any })).rejects.toThrow( + /AbortSignal/, + ); + // the lone-init-dict call shape carries auth options too, and a 2nd init overlays it per field + const viaInit = await Bun.aws.fetch({ + url: echo.url.href, + accessKeyId, + secretAccessKey, + service: "s3", + region: "us-east-1", + } as any); + expect((await viaInit.json()).headers.authorization).toStartWith(`AWS4-HMAC-SHA256 Credential=${accessKeyId}/`); + const overlaid = await Bun.aws.fetch( + { url: echo.url.href, accessKeyId, secretAccessKey, service: "s3", region: "us-east-1" } as any, + { region: "eu-west-1", headers: { "x-extra": "1" } } as any, + ); + const seen = (await overlaid.json()).headers; + expect(seen.authorization).toContain(`Credential=${accessKeyId}/`); + expect(seen.authorization).toContain("/eu-west-1/s3/aws4_request"); + expect(seen["x-extra"]).toBe("1"); + // init.signal: null detaches the Request's signal (as in plain fetch) + const res = await Bun.aws.fetch(new Request(echo.url, { signal: ac.signal }), { + accessKeyId, + secretAccessKey, + service: "s3", + region: "us-east-1", + signal: null, + }); + expect(res.status).toBe(200); + }); + + test("signQuery to S3 with a body signs UNSIGNED-PAYLOAD (what S3 verifies)", async () => { + const hit = await signedFetch("/bucket/key", { + method: "PUT", + body: "hello", + aws: { accessKeyId, secretAccessKey, service: "s3", region: "us-east-1", signQuery: true, signingDate: datetime }, + }); + expect(hit.body).toBe("hello"); + expect(hit.headers.authorization).toBeUndefined(); + const { expected, actual } = referencePresignCheck(hit.url, { + method: "PUT", + service: "s3", + region: "us-east-1", + secretAccessKey, + }); + expect(actual).toBe(expected); + }); + + test("signed requests do not follow redirects by default", async () => { + using redirector = Bun.serve({ + port: 0, + fetch: () => new Response(null, { status: 307, headers: { location: echo.url.href } }), + }); + const base = { accessKeyId, secretAccessKey, service: "s3", region: "us-east-1" }; + const res = await Bun.aws.fetch(redirector.url, base); + expect(res.status).toBe(307); + const viaRequest = await Bun.aws.fetch(new Request(redirector.url), base); + expect(viaRequest.status).toBe(307); + const followed = await Bun.aws.fetch(redirector.url, { ...base, redirect: "follow" }); + expect(followed.status).toBe(200); + // Following a cross-origin redirect drops the signature *and* the session token. + const hop = await Bun.aws.fetch(redirector.url, { ...base, sessionToken: "temporary", redirect: "follow" }); + const seen = (await hop.json()).headers; + expect(seen.authorization).toBeUndefined(); + expect(seen["x-amz-security-token"]).toBeUndefined(); + expect(seen["x-amz-date"]).toBeString(); // (non-secret signing headers may remain) + }); + + test("AWSClient instances carry their own defaults; per-call options override", async () => { + const east = new Bun.AWSClient({ + accessKeyId, + secretAccessKey, + region: "us-east-1", + service: "sqs", + signingDate: datetime, + }); + const west = new Bun.AWSClient({ accessKeyId: "AKIAOTHER", secretAccessKey, region: "us-west-2", service: "sqs" }); + expect(east).toBeInstanceOf(Bun.AWSClient); + expect(Bun.aws).toBeInstanceOf(Bun.AWSClient); + expect(east.region).toBe("us-east-1"); + expect(west.region).toBe("us-west-2"); + expect(east.profile).toBeUndefined(); + const a: Echo = await (await east.fetch(echo.url)).json(); + expect(a.headers.authorization).toContain("Credential=AKIDEXAMPLE/20150830/us-east-1/sqs/"); + const b: Echo = await (await west.fetch(echo.url, { signingDate: datetime })).json(); + expect(b.headers.authorization).toContain("Credential=AKIAOTHER/20150830/us-west-2/sqs/"); + const c: Echo = await (await east.fetch(echo.url, { region: "ap-south-1", service: "sns" })).json(); + expect(c.headers.authorization).toContain("/ap-south-1/sns/"); + expect(await east.credentials()).toEqual({ accessKeyId, secretAccessKey, source: "explicit" }); + expect((await west.credentials()).accessKeyId).toBe("AKIAOTHER"); + expect( + new URL(await east.presign("https://bkt.s3.amazonaws.com/k")).searchParams.get("X-Amz-Credential"), + ).toStartWith("AKIDEXAMPLE/"); + // endpoint: base URL for relative paths (LocalStack-style) + const local = new Bun.AWSClient({ + accessKeyId, + secretAccessKey, + region: "us-east-1", + service: "sqs", + endpoint: echo.url.href, + }); + const d: Echo = await (await local.fetch("/queue?Action=Purge")).json(); + expect(new URL(d.url).pathname + new URL(d.url).search).toBe("/queue?Action=Purge"); + // @ts-expect-error + expect(() => new Bun.AWSClient("nope")).toThrow(/expected an options object/); + }); + + test("Request objects and the init-object form work too", async () => { + const req = new Request(new URL("/from-request", echo.url), { method: "DELETE" }); + const hit: Echo = await ( + await Bun.aws.fetch(req, { + accessKeyId, + secretAccessKey, + service: "sqs", + region: "us-east-1", + signingDate: datetime, + }) + ).json(); + expect(hit.method).toBe("DELETE"); + expect(hit.headers.authorization).toBe( + referenceSign({ + method: "DELETE", + url: new URL("/from-request", echo.url).href, + service: "sqs", + region: "us-east-1", + accessKeyId, + secretAccessKey, + datetime, + }).authorization, + ); + }); +}); + +describe("Bun.aws.presign", () => { + test("S3 object URL", async () => { + const url = await Bun.aws.presign( + "https://my-bucket.s3.eu-west-1.amazonaws.com/some dir/photo (1).jpg?versionId=3", + { + accessKeyId, + secretAccessKey, + sessionToken: "the token", + expiresIn: 3600, + signingDate: datetime, + }, + ); + const parsed = new URL(url); + expect(parsed.origin).toBe("https://my-bucket.s3.eu-west-1.amazonaws.com"); + expect(parsed.pathname).toBe("/some%20dir/photo%20%281%29.jpg"); + expect(parsed.searchParams.get("versionId")).toBe("3"); + expect(parsed.searchParams.get("X-Amz-Expires")).toBe("3600"); + expect(parsed.searchParams.get("X-Amz-Credential")).toBe("AKIDEXAMPLE/20150830/eu-west-1/s3/aws4_request"); + expect(parsed.searchParams.get("X-Amz-Security-Token")).toBe("the token"); + expect([...parsed.searchParams.keys()].at(-1)).toBe("X-Amz-Signature"); + const { expected, actual } = referencePresignCheck(url, { service: "s3", region: "eu-west-1", secretAccessKey }); + expect(actual).toBe(expected); + }); + + test("method, URL objects, non-S3 services, defaults", async () => { + const put = await Bun.aws.presign(new URL("https://bucket.s3.amazonaws.com/upload.bin"), { + accessKeyId, + secretAccessKey, + method: "PUT", + signingDate: new Date("2015-08-30T12:36:00Z"), + }); + expect(new URL(put).searchParams.get("X-Amz-Expires")).toBe("900"); + expect(new URL(put).searchParams.get("X-Amz-Date")).toBe(datetime); + const { expected, actual } = referencePresignCheck(put, { + method: "PUT", + service: "s3", + region: "us-east-1", + secretAccessKey, + }); + expect(actual).toBe(expected); + + const iot = await Bun.aws.presign("https://data-ats.iot.us-east-1.amazonaws.com/mqtt", { + accessKeyId, + secretAccessKey, + service: "iotdevicegateway", + region: "us-east-1", + signingDate: datetime, + }); + const check = referencePresignCheck(iot, { service: "iotdevicegateway", region: "us-east-1", secretAccessKey }); + expect(check.actual).toBe(check.expected); + }); + + test("validation", async () => { + const base = { accessKeyId, secretAccessKey }; + // Argument errors throw synchronously; anything that needs credentials rejects. + expect(() => Bun.aws.presign("ftp://x/y", base)).toThrow(/http: or https:/); + expect(() => Bun.aws.presign("https://bucket.s3.amazonaws.com/k", { ...base, expiresIn: 0 })).toThrow(/expiresIn/); + expect(() => Bun.aws.presign("https://bucket.s3.amazonaws.com/k", { ...base, expiresIn: 604801 })).toThrow( + /expiresIn/, + ); + // @ts-expect-error + expect(() => Bun.aws.presign()).toThrow(/expects a URL/); + expect(() => Bun.aws.presign("https://bucket.s3.amazonaws.com/k", { ...base, method: "NOPE" })).toThrow(/method/); + await expect(Bun.aws.presign("https://localhost/k", base)).rejects.toThrow(/cannot tell which AWS service/); + expect(Bun.aws.presign("https://bucket.s3.amazonaws.com/k", base)).toBeInstanceOf(Promise); + }); +}); diff --git a/test/js/bun/aws/sigv4-reference.ts b/test/js/bun/aws/sigv4-reference.ts new file mode 100644 index 000000000000..026f0e2c89c8 --- /dev/null +++ b/test/js/bun/aws/sigv4-reference.ts @@ -0,0 +1,141 @@ +// Independent, deliberately straightforward SigV4 implementation used as an +// oracle for Bun's native signer. Follows +// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html +import { createHash, createHmac } from "node:crypto"; + +const enc = (s: string) => + encodeURIComponent(s).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); + +function hmac(key: string | Buffer, data: string) { + return createHmac("sha256", key).update(data, "utf8").digest(); +} + +export function sha256Hex(data: string | Uint8Array) { + return createHash("sha256").update(data).digest("hex"); +} + +export type ReferenceRequest = { + method: string; + url: string; + headers?: Record; + body?: string | Uint8Array; + service: string; + region: string; + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + /** YYYYMMDDTHHMMSSZ */ + datetime: string; + unsignedPayload?: boolean; +}; + +function canonicalUri(pathname: string, s3: boolean) { + if (pathname === "") return "/"; + if (s3) { + return decodeURIComponent(pathname) + .split("/") + .map(seg => enc(seg)) + .join("/"); + } + const segments: string[] = []; + for (const seg of pathname.split("/")) { + if (seg === "" || seg === ".") continue; + if (seg === "..") segments.pop(); + else segments.push(enc(seg)); + } + let out = "/" + segments.join("/"); + if (segments.length && pathname.endsWith("/")) out += "/"; + return out; +} + +function canonicalQuery(search: string) { + if (search.startsWith("?")) search = search.slice(1); + if (!search) return ""; + const pairs: [string, string][] = []; + for (const part of search.split("&")) { + if (!part) continue; + const i = part.indexOf("="); + const k = i === -1 ? part : part.slice(0, i); + const v = i === -1 ? "" : part.slice(i + 1); + if (k === "X-Amz-Signature") continue; + const dec = (s: string) => { + try { + return decodeURIComponent(s); + } catch { + return s; + } + }; + pairs.push([enc(dec(k)), enc(dec(v))]); + } + pairs.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)); + return pairs.map(([k, v]) => `${k}=${v}`).join("&"); +} + +/** Returns the headers Bun should add (lowercase names). */ +export function referenceSign(req: ReferenceRequest) { + const url = new URL(req.url); + const s3 = req.service === "s3"; + const payloadHash = req.unsignedPayload ? "UNSIGNED-PAYLOAD" : sha256Hex(req.body ?? ""); + const headers: [string, string][] = [ + ["host", url.host], + ["x-amz-date", req.datetime], + ]; + if (s3) headers.push(["x-amz-content-sha256", payloadHash]); + if (req.sessionToken) headers.push(["x-amz-security-token", req.sessionToken]); + for (const [k, v] of Object.entries(req.headers ?? {})) { + const name = k.toLowerCase(); + if (["host", "authorization", "user-agent", "content-length", "connection", "x-amz-date"].includes(name)) continue; + headers.push([name, v.trim().replace(/[ \t]+/g, " ")]); + } + headers.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + const signedHeaders = headers.map(h => h[0]).join(";"); + const canonical = [ + req.method, + canonicalUri(url.pathname, s3), + canonicalQuery(url.search), + headers.map(([k, v]) => `${k}:${v}\n`).join(""), + signedHeaders, + payloadHash, + ].join("\n"); + const date = req.datetime.slice(0, 8); + const scope = `${date}/${req.region}/${req.service}/aws4_request`; + const stringToSign = ["AWS4-HMAC-SHA256", req.datetime, scope, sha256Hex(canonical)].join("\n"); + const kSigning = hmac(hmac(hmac(hmac("AWS4" + req.secretAccessKey, date), req.region), req.service), "aws4_request"); + const signature = createHmac("sha256", kSigning).update(stringToSign).digest("hex"); + return { + canonical, + stringToSign, + authorization: `AWS4-HMAC-SHA256 Credential=${req.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`, + "x-amz-date": req.datetime, + "x-amz-content-sha256": payloadHash, + }; +} + +/** Recomputes the signature of a presigned URL and returns [expected, actual]. */ +export function referencePresignCheck( + presigned: string, + opts: { method?: string; service: string; region: string; secretAccessKey: string }, +) { + const url = new URL(presigned); + const actual = url.searchParams.get("X-Amz-Signature"); + const datetime = url.searchParams.get("X-Amz-Date")!; + const s3 = opts.service === "s3"; + const payloadHash = s3 ? "UNSIGNED-PAYLOAD" : sha256Hex(""); + const canonical = [ + opts.method ?? "GET", + canonicalUri(url.pathname, s3), + canonicalQuery(url.search), + `host:${url.host}\n`, + "host", + payloadHash, + ].join("\n"); + const date = datetime.slice(0, 8); + const scope = `${date}/${opts.region}/${opts.service}/aws4_request`; + const stringToSign = ["AWS4-HMAC-SHA256", datetime, scope, sha256Hex(canonical)].join("\n"); + const kSigning = hmac( + hmac(hmac(hmac("AWS4" + opts.secretAccessKey, date), opts.region), opts.service), + "aws4_request", + ); + const expected = createHmac("sha256", kSigning).update(stringToSign).digest("hex"); + return { expected, actual, canonical }; +} diff --git a/test/js/bun/gcp/gcp-credentials.test.ts b/test/js/bun/gcp/gcp-credentials.test.ts new file mode 100644 index 000000000000..10a99ba48b3b --- /dev/null +++ b/test/js/bun/gcp/gcp-credentials.test.ts @@ -0,0 +1,457 @@ +import type { Server } from "bun"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createVerify, generateKeyPairSync } from "crypto"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { join } from "path"; + +const STRIPPED = [ + "GOOGLE_APPLICATION_CREDENTIALS", + "CLOUDSDK_CONFIG", + "GCE_METADATA_HOST", + "GCE_METADATA_IP", + "GCE_METADATA_TIMEOUT", + "NO_GCE_CHECK", + "GOOGLE_CLOUD_QUOTA_PROJECT", + "GOOGLE_CLOUD_UNIVERSE_DOMAIN", + "HTTPS_PROXY", + "https_proxy", +]; + +let baseEnv: Record; +let tokenServer: Server, metadata: Server, echo: Server; +type Hit = { method: string; path: string; headers: Record; body: string }; +const hits = { token: [] as Hit[], metadata: [] as Hit[] }; +const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); +const privateKeyPem = privateKey.export({ format: "pem", type: "pkcs8" }) as string; + +function b64urlDecode(s: string) { + return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64"); +} + +function fakeJwt(claims: Record) { + const enc = (o: unknown) => Buffer.from(JSON.stringify(o)).toString("base64url"); + return `${enc({ alg: "RS256", typ: "JWT" })}.${enc(claims)}.c2ln`; +} + +async function record(list: Hit[], req: Request) { + const url = new URL(req.url); + const hit = { + method: req.method, + path: url.pathname + url.search, + headers: Object.fromEntries(req.headers), + body: await req.text(), + }; + list.push(hit); + return hit; +} + +let home: ReturnType; +beforeAll(() => { + home = tempDir("gcp-home", { ".config": { placeholder: "" } }); + baseEnv = { ...bunEnv, HOME: String(home), USERPROFILE: String(home), APPDATA: String(home), NO_GCE_CHECK: "true" }; + for (const k of STRIPPED) baseEnv[k] = undefined; + baseEnv.NO_GCE_CHECK = "true"; + + // oauth2.googleapis.com/token stand-in + tokenServer = Bun.serve({ + port: 0, + async fetch(req) { + const hit = await record(hits.token, req); + const form = new URLSearchParams(hit.body); + const grant = form.get("grant_type"); + if (grant === "urn:ietf:params:oauth:grant-type:jwt-bearer") { + const assertion = form.get("assertion")!; + const [h, p, sig] = assertion.split("."); + const header = JSON.parse(b64urlDecode(h).toString()); + const claims = JSON.parse(b64urlDecode(p).toString()); + const ok = createVerify("RSA-SHA256").update(`${h}.${p}`).verify(publicKey, b64urlDecode(sig)); + if (!ok || header.alg !== "RS256") { + return Response.json( + { error: "invalid_grant", error_description: "Invalid JWT Signature." }, + { status: 400 }, + ); + } + if (claims.iss === "denied@proj.iam.gserviceaccount.com") { + return Response.json({ error: "invalid_grant", error_description: "account not found" }, { status: 400 }); + } + if (claims.target_audience) { + return Response.json({ + id_token: fakeJwt({ aud: claims.target_audience, iss: "https://accounts.google.com", exp: 4102444800 }), + }); + } + return Response.json({ + access_token: `sa-token-for:${claims.iss}:${claims.scope}:kid=${header.kid}`, + expires_in: 3599, + token_type: "Bearer", + }); + } + if (grant === "refresh_token") { + if (form.get("refresh_token") !== "1//refresh-me") { + return Response.json({ error: "invalid_grant", error_description: "Bad Request" }, { status: 400 }); + } + return Response.json({ + access_token: `user-token:${form.get("client_id")}:${form.get("scope") ?? "default"}`, + expires_in: 3599, + scope: "https://www.googleapis.com/auth/cloud-platform", + token_type: "Bearer", + // Google honours target_audience for some clients; model both. + id_token: fakeJwt({ + aud: form.get("target_audience")?.startsWith("https://honoured.") + ? form.get("target_audience") + : form.get("client_id"), + exp: 4102444800, + }), + }); + } + return new Response("unknown grant", { status: 400 }); + }, + }); + + metadata = Bun.serve({ + port: 0, + async fetch(req) { + const hit = await record(hits.metadata, req); + const headers = { "metadata-flavor": "Google" }; + if (req.headers.get("metadata-flavor") !== "Google") { + return new Response("Missing Metadata-Flavor:Google header", { status: 403, headers }); + } + const url = new URL(req.url); + switch (url.pathname) { + case "/computeMetadata/v1/instance/service-accounts/default/token": + return Response.json( + { + access_token: `metadata-token:${url.searchParams.get("scopes") ?? "default"}`, + expires_in: 3000, + token_type: "Bearer", + }, + { headers }, + ); + case "/computeMetadata/v1/instance/service-accounts/default/identity": + return new Response(fakeJwt({ aud: url.searchParams.get("audience"), exp: 4102444800 }), { headers }); + case "/computeMetadata/v1/instance/service-accounts/default/email": + return new Response("vm@proj.iam.gserviceaccount.com", { headers }); + case "/computeMetadata/v1/project/project-id": + return new Response("my-project", { headers }); + } + return new Response("not found", { status: 404, headers }); + }, + }); + + echo = Bun.serve({ + port: 0, + fetch(req) { + return Response.json(Object.fromEntries(req.headers)); + }, + }); +}); + +afterAll(() => { + for (const s of [tokenServer, metadata, echo]) s?.stop(true); + home?.[Symbol.dispose](); +}); + +async function run(code: string, env: Record) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: { ...baseEnv, ...env } as any, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +const TOKEN_SCRIPT = (call: string) => ` + try { + const t = await ${call}; + console.log(JSON.stringify({ ...t, expiration: t.expiration?.toISOString() })); + } catch (e) { + console.log(JSON.stringify({ error: { code: e.code, message: e.message } })); + } +`; + +async function token(env: Record, call = "Bun.gcp.accessToken()") { + const { stdout, stderr, exitCode } = await run(TOKEN_SCRIPT(call), env); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + return JSON.parse(stdout.trim()); +} + +function serviceAccountFile(email = "robot@proj.iam.gserviceaccount.com") { + return JSON.stringify({ + type: "service_account", + project_id: "proj", + private_key_id: "key-1", + private_key: privateKeyPem, + client_email: email, + client_id: "1234", + token_uri: `http://127.0.0.1:${tokenServer.port}/token`, + }); +} + +function authorizedUserFile() { + return JSON.stringify({ + type: "authorized_user", + client_id: "client.apps.googleusercontent.com", + client_secret: "shh", + refresh_token: "1//refresh-me", + quota_project_id: "billing-proj", + token_uri: `http://127.0.0.1:${tokenServer.port}/token`, + }); +} + +describe.concurrent("Bun.gcp", () => { + test("nothing configured → ERR_GCP_MISSING_CREDENTIALS naming every source", async () => { + const r = await token({}); + expect(r.error.code).toBe("ERR_GCP_MISSING_CREDENTIALS"); + expect(r.error.message).toContain("GOOGLE_APPLICATION_CREDENTIALS (not set)"); + expect(r.error.message).toContain("application_default_credentials.json"); + expect(r.error.message).toContain("NO_GCE_CHECK"); + }); + + test("a well-known ADC file that exists but cannot be read is an error (like google-auth-library)", async () => { + using cfgdir = tempDir("gcp-unreadable", { "application_default_credentials.json": { x: "" } }); // a directory + const blocked = await token({ + CLOUDSDK_CONFIG: String(cfgdir), + NO_GCE_CHECK: undefined, + GCE_METADATA_HOST: `127.0.0.1:${metadata.port}`, + }); + expect(blocked.error.code).toBe("ERR_GCP_CREDENTIALS"); + expect(blocked.error.message).toMatch(/could not read .*application_default_credentials\.json \(E[A-Z]+\)/); + }); + + test("service account key file → RS256-signed JWT exchanged for an access token", async () => { + using dir = tempDir("gcp-sa", { "sa.json": serviceAccountFile() }); + const before = hits.token.length; + const r = await token({ GOOGLE_APPLICATION_CREDENTIALS: join(dir, "sa.json") }); + expect(r).toEqual({ + token: "sa-token-for:robot@proj.iam.gserviceaccount.com:https://www.googleapis.com/auth/cloud-platform:kid=key-1", + expiration: expect.any(String), + source: "service-account", + email: "robot@proj.iam.gserviceaccount.com", + projectId: "proj", + }); + expect(new Date(r.expiration).getTime()).toBeGreaterThan(Date.now() + 3000_000); + const hit = hits.token.slice(before).find(h => { + const form = new URLSearchParams(h.body); + if (form.get("grant_type") !== "urn:ietf:params:oauth:grant-type:jwt-bearer") return false; + const c = JSON.parse(b64urlDecode(form.get("assertion")!.split(".")[1]).toString()); + return ( + c.iss === "robot@proj.iam.gserviceaccount.com" && c.scope === "https://www.googleapis.com/auth/cloud-platform" + ); + })!; + const claims = JSON.parse(b64urlDecode(new URLSearchParams(hit.body).get("assertion")!.split(".")[1]).toString()); + expect(claims).toEqual({ + iss: "robot@proj.iam.gserviceaccount.com", + sub: "robot@proj.iam.gserviceaccount.com", + aud: `http://127.0.0.1:${tokenServer.port}/token`, + scope: "https://www.googleapis.com/auth/cloud-platform", + iat: expect.any(Number), + exp: claims.iat + 3600, + }); + + // custom scopes (bare names expand), and an ID token for an audience + const scoped = await token( + { GOOGLE_APPLICATION_CREDENTIALS: join(dir, "sa.json") }, + `Bun.gcp.accessToken({ scopes: ["devstorage.read_only", "https://www.googleapis.com/auth/pubsub"] })`, + ); + expect(scoped.token).toEndWith( + ":https://www.googleapis.com/auth/devstorage.read_only https://www.googleapis.com/auth/pubsub:kid=key-1", + ); + const id = await token( + { GOOGLE_APPLICATION_CREDENTIALS: join(dir, "sa.json") }, + `Bun.gcp.idToken("https://my-service-abc.a.run.app")`, + ); + expect(id.source).toBe("service-account"); + expect(JSON.parse(b64urlDecode(id.token.split(".")[1]).toString()).aud).toBe("https://my-service-abc.a.run.app"); + expect(id.expiration).toBe("2100-01-01T00:00:00.000Z"); + + // token endpoint errors carry Google's error_description + using denied = tempDir("gcp-sa-denied", { "sa.json": serviceAccountFile("denied@proj.iam.gserviceaccount.com") }); + const bad = await token({ GOOGLE_APPLICATION_CREDENTIALS: join(denied, "sa.json") }); + expect(bad.error.code).toBe("ERR_GCP_CREDENTIALS"); + expect(bad.error.message).toContain("invalid_grant: account not found"); + }); + + test("authorized_user ADC file from the well-known gcloud location", async () => { + using cfgdir = tempDir("gcp-gcloud", { "application_default_credentials.json": authorizedUserFile() }); + const r = await token({ CLOUDSDK_CONFIG: String(cfgdir) }); + expect(r).toEqual({ + token: "user-token:client.apps.googleusercontent.com:default", + expiration: expect.any(String), + source: "authorized-user", + quotaProjectId: "billing-proj", + }); + // ID tokens: `target_audience` is sent and whatever Google issues is + // returned, as google-auth-library does (for user credentials that is + // usually a token for gcloud's client ID, which Cloud Run accepts). + const aud = async (audience: string) => + JSON.parse( + b64urlDecode( + ( + await token({ CLOUDSDK_CONFIG: String(cfgdir) }, `Bun.gcp.idToken(${JSON.stringify(audience)})`) + ).token.split(".")[1], + ).toString(), + ).aud; + expect(await aud("https://honoured.run.app")).toBe("https://honoured.run.app"); + expect(await aud("https://svc.a.run.app")).toBe("client.apps.googleusercontent.com"); + // unsupported credential types say so + using ext = tempDir("gcp-ext", { "creds.json": JSON.stringify({ type: "external_account", audience: "x" }) }); + const e = await token({ GOOGLE_APPLICATION_CREDENTIALS: join(ext, "creds.json") }); + expect(e.error.message).toContain('type "external_account"'); + // a missing GOOGLE_APPLICATION_CREDENTIALS file is a hard error + const missing = await token({ GOOGLE_APPLICATION_CREDENTIALS: join(ext, "nope.json") }); + expect(missing.error.message).toContain("could not read credentials file"); + }); + + test.skipIf(isWindows)("authorized_user ADC file under ~/.config/gcloud (POSIX layout)", async () => { + using home = tempDir("gcp-home2", { + ".config": { gcloud: { "application_default_credentials.json": authorizedUserFile() } }, + }); + expect(await token({ HOME: String(home) })).toMatchObject({ source: "authorized-user" }); + }); + test("metadata server (GCE / GKE / Cloud Run)", async () => { + const env = { NO_GCE_CHECK: undefined, GCE_METADATA_HOST: `127.0.0.1:${metadata.port}` }; + const before = hits.metadata.length; + expect(await token(env)).toEqual({ + token: "metadata-token:default", + expiration: expect.any(String), + source: "metadata", + email: "vm@proj.iam.gserviceaccount.com", + projectId: "my-project", + }); + for (const hit of hits.metadata.slice(before)) expect(hit.headers["metadata-flavor"]).toBe("Google"); + + expect((await token(env, `Bun.gcp.accessToken({ scopes: "bigquery,pubsub" })`)).token).toBe( + "metadata-token:https://www.googleapis.com/auth/bigquery,https://www.googleapis.com/auth/pubsub", + ); + const id = await token(env, `Bun.gcp.idToken({ audience: "https://example.com" })`); + expect(JSON.parse(b64urlDecode(id.token.split(".")[1]).toString()).aud).toBe("https://example.com"); + expect(id.source).toBe("metadata"); + + { + // a VM with no service account attached + using bare = Bun.serve({ + port: 0, + fetch: () => new Response("Not Found", { status: 404, headers: { "metadata-flavor": "Google" } }), + }); + const none = await token({ NO_GCE_CHECK: undefined, GCE_METADATA_HOST: `127.0.0.1:${bare.port}` }); + expect(none.error.code).toBe("ERR_GCP_CREDENTIALS"); + expect(none.error.message).toContain("no default service account"); + } + + // an unreachable metadata host means "not on GCP" + const off = await token({ NO_GCE_CHECK: undefined, GCE_METADATA_HOST: "127.0.0.1:9", GCE_METADATA_TIMEOUT: "2" }); + expect(off.error.code).toBe("ERR_GCP_MISSING_CREDENTIALS"); + expect(off.error.message).toContain("is unreachable"); + }); + + test("tokens are cached per scope set; refresh: true re-fetches", async () => { + const env = { NO_GCE_CHECK: undefined, GCE_METADATA_HOST: `127.0.0.1:${metadata.port}` }; + // Scopes unique to this test so concurrent tests' hits don't count. + const count = () => hits.metadata.filter(h => h.path.includes("cachetest")).length; + const before = count(); + const { stdout, exitCode } = await run( + ` + const one = { scopes: ["https://www.googleapis.com/auth/cachetest1"] }; + const a = await Bun.gcp.accessToken(one); + const b = await Bun.gcp.accessToken(one); + const c = await Bun.gcp.accessToken({ scopes: "https://www.googleapis.com/auth/cachetest1" }); // same set + const d = await Bun.gcp.accessToken({ scopes: "cachetest2" }); + const [e, f] = await Promise.all([Bun.gcp.accessToken({ ...one, refresh: true }), Bun.gcp.accessToken(one)]); + console.log([a, b, c, d, e, f].map(t => t.token.replace("metadata-token:https://www.googleapis.com/auth/", "")).join(" ")); + `, + env, + ); + expect(stdout.trim()).toBe("cachetest1 cachetest1 cachetest1 cachetest2 cachetest1 cachetest1"); + expect(exitCode).toBe(0); + expect(count() - before).toBe(3); + }); + + test("Bun.gcp.fetch adds the bearer token and quota project", async () => { + using cfgdir = tempDir("gcp-fetch", { "application_default_credentials.json": authorizedUserFile() }); + using sa = tempDir("gcp-fetch-sa", { "sa.json": serviceAccountFile() }); + const { stdout, stderr, exitCode } = await run( + ` + const echo = ${JSON.stringify(echo.url.href)}; + const a = await (await Bun.gcp.fetch(echo)).json(); + const b = await (await Bun.gcp.fetch(echo, { scopes: ["bigquery"], headers: { "x-goog-user-project": "mine" } })).json(); + process.env.GOOGLE_APPLICATION_CREDENTIALS = ${JSON.stringify(join(sa, "sa.json"))}; + const c = await (await Bun.gcp.fetch(echo, { audience: "https://run.app/x" })).json(); + // { url, ...init } shape, with a second init that does not name a token kind + const c2 = await (await Bun.gcp.fetch({ url: echo, audience: "https://run.app/x" }, { headers: { "x-extra": "1" } })).json(); + if (c2.authorization !== c.authorization || c2["x-extra"] !== "1") throw new Error("init-dict overlay: " + JSON.stringify(c2)); + const errors = []; + for (const init of [{ audience: "a", scopes: "b" }, { headers: { Authorization: "x" } }]) { + try { await Bun.gcp.fetch(echo, init); errors.push("no error"); } catch (e) { errors.push(e.message); } + } + try { await Bun.gcp.fetch("s3://b/k"); errors.push("no error"); } catch (e) { errors.push(e.message); } + console.log(JSON.stringify({ a, b, c, errors })); + `, + { CLOUDSDK_CONFIG: String(cfgdir) }, + ); + expect(stderr).toBe(""); + const { a, b, c, errors } = JSON.parse(stdout.trim()); + expect(a.authorization).toBe("Bearer user-token:client.apps.googleusercontent.com:default"); + expect(a["x-goog-user-project"]).toBe("billing-proj"); + expect(b.authorization).toBe( + "Bearer user-token:client.apps.googleusercontent.com:https://www.googleapis.com/auth/bigquery", + ); + expect(b["x-goog-user-project"]).toBe("mine"); + expect(c.authorization).toStartWith("Bearer ey"); + expect(JSON.parse(b64urlDecode(c.authorization.split(".")[1]).toString()).aud).toBe("https://run.app/x"); + expect(errors).toEqual([ + expect.stringContaining("mutually exclusive"), + expect.stringContaining('sets the "Authorization" header itself'), + expect.stringContaining("s3:// URLs"), + ]); + expect(exitCode).toBe(0); + }); + + test("GCPClient instances: keyFile / inline credentials / default audience", async () => { + using dir = tempDir("gcp-clients", { + "a.json": serviceAccountFile("a@proj.iam.gserviceaccount.com"), + "b.json": serviceAccountFile("b@proj.iam.gserviceaccount.com"), + }); + const { stdout, stderr, exitCode } = await run( + ` + const echo = ${JSON.stringify(echo.url.href)}; + const a = new Bun.GCPClient({ keyFile: ${JSON.stringify(join(dir, "a.json"))} }); + const b = new Bun.GCPClient({ credentials: await Bun.file(${JSON.stringify(join(dir, "b.json"))}).json(), scopes: ["bigquery"] }); + const c = new Bun.GCPClient({ credentials: await Bun.file(${JSON.stringify(join(dir, "a.json"))}).text(), audience: "https://svc.run.app" }); + const [ta, tb, tc] = await Promise.all([a.accessToken(), b.accessToken(), c.idToken()]); + const viaFetch = await (await b.fetch(echo)).json(); + const override = await (await b.fetch(echo, { scopes: "pubsub" })).json(); + console.log(JSON.stringify({ + a: ta.token, aEmail: ta.email, b: tb.token, cAud: JSON.parse(atob(tc.token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))).aud, + viaFetch: viaFetch.authorization, override: override.authorization, + isInstance: Bun.gcp instanceof Bun.GCPClient && a instanceof Bun.GCPClient, + })); + `, + {}, + ); + expect(stderr).toBe(""); + const out = JSON.parse(stdout.trim()); + expect(out).toEqual({ + a: "sa-token-for:a@proj.iam.gserviceaccount.com:https://www.googleapis.com/auth/cloud-platform:kid=key-1", + aEmail: "a@proj.iam.gserviceaccount.com", + b: "sa-token-for:b@proj.iam.gserviceaccount.com:https://www.googleapis.com/auth/bigquery:kid=key-1", + cAud: "https://svc.run.app", + viaFetch: "Bearer sa-token-for:b@proj.iam.gserviceaccount.com:https://www.googleapis.com/auth/bigquery:kid=key-1", + override: "Bearer sa-token-for:b@proj.iam.gserviceaccount.com:https://www.googleapis.com/auth/pubsub:kid=key-1", + isInstance: true, + }); + expect(exitCode).toBe(0); + }); + + test("argument validation", () => { + // @ts-expect-error + expect(() => Bun.gcp.accessToken("cloud-platform")).toThrow("options object"); + // @ts-expect-error + expect(() => new Bun.GCPClient(1)).toThrow("options must be an object"); + expect(() => new Bun.GCPClient({ credentials: 1 as any })).toThrow("credentials must be"); + expect(() => Bun.gcp.accessToken({ scopes: [123 as any] })).toThrow("scope"); + expect(() => Bun.gcp.idToken()).toThrow("audience"); + expect(() => Bun.gcp.idToken({ audience: "" })).toThrow("audience"); + }); +}); diff --git a/test/js/bun/glob/proto.test.ts b/test/js/bun/glob/proto.test.ts index e5f0b5b19c60..e09e483367c9 100644 --- a/test/js/bun/glob/proto.test.ts +++ b/test/js/bun/glob/proto.test.ts @@ -44,3 +44,11 @@ test("Object prototype followSymlinks", async () => { }); expect([...third].map(a => a.replaceAll("\\", "/"))).toEqual(["def/file.txt"]); }); + +test("builtin-implemented methods carry the same property attributes as native ones", () => { + // `scan`/`scanSync` are JS builtins on a `configurable: false` class; they + // used to be emitted without DontDelete while `match` (native) had it. + for (const name of ["scan", "scanSync", "match"]) { + expect(Object.getOwnPropertyDescriptor(Bun.Glob.prototype, name)?.configurable).toBe(false); + } +}); diff --git a/test/js/bun/s3/s3-list-objects.test.ts b/test/js/bun/s3/s3-list-objects.test.ts index c7e45939816d..3f4add8d43cf 100644 --- a/test/js/bun/s3/s3-list-objects.test.ts +++ b/test/js/bun/s3/s3-list-objects.test.ts @@ -1,6 +1,8 @@ import { randomUUIDv7, S3Client, S3Options } from "bun"; import { afterAll, describe, expect, it } from "bun:test"; -import { getSecret } from "harness"; +import { getSecret, isolateAwsCredentialChain } from "harness"; + +isolateAwsCredentialChain(); const options: S3Options = { accessKeyId: "test", diff --git a/test/js/bun/s3/s3.test.ts b/test/js/bun/s3/s3.test.ts index 645b84c390cd..7de71a081fd1 100644 --- a/test/js/bun/s3/s3.test.ts +++ b/test/js/bun/s3/s3.test.ts @@ -3,7 +3,17 @@ import { S3Client, s3 as defaultS3, file, randomUUIDv7 } from "bun"; import { describe, expect, it } from "bun:test"; import child_process from "child_process"; import { createHash, createHmac, randomUUID } from "crypto"; -import { bunEnv, bunExe, dockerExe, getSecret, isCI, isDockerEnabled, tempDir, tempDirWithFiles } from "harness"; +import { + bunEnv, + bunExe, + dockerExe, + getSecret, + isCI, + isDockerEnabled, + isolateAwsCredentialChain, + tempDir, + tempDirWithFiles, +} from "harness"; import path from "path"; const s3 = (...args) => defaultS3.file(...args); const S3 = (...args) => new S3Client(...args); @@ -11,6 +21,8 @@ const S3 = (...args) => new S3Client(...args); // Import docker-compose helper import * as dockerCompose from "../../../docker/index.ts"; +isolateAwsCredentialChain(); + const dockerCLI = dockerExe() as string; type S3Credentials = S3Options & { service: string;