From f371fd2da638f45f606df97597003ecca460fed1 Mon Sep 17 00:00:00 2001 From: Jean Lucas de Carvalho Date: Tue, 28 Jul 2026 18:16:06 -0300 Subject: [PATCH 1/2] feat: optionally serve installers with a filename tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub's signed asset URLs carry `response-content-disposition` inside the signature, so a redirect cannot control the filename the browser saves. This adds an optional mirror: each release asset is copied once to an S3-compatible bucket, and `/download` and `/download/:platform` accept `?t=` to serve the Windows installer through a presigned URL that forces ` [].exe`. Lets an installed client correlate the download it came from. Still no bytes through this server — both paths are redirects. Degrades quietly: mirror not configured, malformed tag, or release not copied yet all fall back to the plain redirect, so the download never fails. With no bucket configured every route behaves exactly as before, which makes it safe to deploy ahead of the infrastructure. `?t=` is ignored for `?update=true`, since a Squirrel update must keep the canonical filename. The object key includes the repository so several deployments can share one bucket. The tag prefix is configurable, and the tag itself is opaque here — it may carry sensitive caller data, so it is never logged. Co-Authored-By: Claude Opus 5 --- README.md | 38 +++++ package.json | 3 + src/lib/mirror.ts | 223 +++++++++++++++++++++++++++++ src/lib/routes.ts | 93 +++++++++++- src/lib/server.ts | 12 ++ src/lib/types.ts | 15 ++ yarn.lock | 355 +++++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 731 insertions(+), 8 deletions(-) create mode 100644 src/lib/mirror.ts diff --git a/README.md b/README.md index 7557dcc..b0bab03 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,30 @@ The following environment variables can be used optionally: - `TOKEN`: Your GitHub token (for private repos) - `PRIVATE_BASE_URL`: The server's URL (for private repos - when running on [Vercel](https://vercel.com), this field is filled with the URL of the deployment automatically) +### Release mirror (optional) + +Mirrors each release asset to an S3-compatible bucket so the Windows installer +can be served with a caller-supplied tag in its filename. Without these +variables the mirror stays off and every download route behaves exactly as +before. + +- `BUCKET_NAME_CLOUDFLARE_R2`, `AWS_ACCESS_KEY_ID_CLOUDFLARE_R2`, + `AWS_SECRET_ACCESS_KEY_CLOUDFLARE_R2`: bucket and credentials. All three are + required to turn the mirror on +- `AWS_ENDPOINT_CLOUDFLARE_R2`, `AWS_DEFAULT_REGION_CLOUDFLARE_R2`: S3 endpoint + and region (region defaults to `auto`) +- `RELEASES_MIRROR_PREFIX`: key prefix in the bucket (defaults to `releases`) +- `INSTALLER_TAG_PREFIX`: prefix placed before the tag in the filename + (defaults to `build-`). The client that reads the filename must be configured + with the same value + +Mirroring is one copy per release, not per download, and it is kicked off in the +background from the traffic the service already receives. + +Object keys are `///`. The repository is +part of the key so that several deployments, each reading a different release +repo, can safely share one bucket. + ## Statistics Since Nutela routes all the traffic for downloading the actual application files to [GitHub Releases](https://help.github.com/articles/creating-releases/), you can use their API to determine the download count for a certain release. @@ -63,6 +87,20 @@ Accepts a platform (like "darwin" or "win32") to download the appropriate copy y If the cache isn't filled yet or doesn't contain a download link for the specified platform, it will respond like `/`. +Both `/download` and `/download/:platform` accept an optional `?t=` for the +Windows installer. When the release mirror is configured and the asset has +already been copied, the response redirects to a presigned URL that forces the +filename ` [].exe`, letting the installed client correlate +the download it came from. Any failure — mirror off, malformed tag, release not +copied yet — silently falls back to the normal redirect, so the user still gets +the same binary. + +`?t=` is ignored when `?update=true`, since a Squirrel update must keep the +canonical filename. + +**The tag is opaque to this service and may carry sensitive caller data. Never +log it, and prefer a short-lived, single-use value.** + ### /update/:platform/:version Checks if there is an update available by reading from the cache. diff --git a/package.json b/package.json index d78873d..9d2d023 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,9 @@ ] }, "dependencies": { + "@aws-sdk/client-s3": "3.1096.0", + "@aws-sdk/lib-storage": "3.1096.0", + "@aws-sdk/s3-request-presigner": "3.1096.0", "async-retry": "1.3.3", "date-fns": "3.6.0", "dotenv": "16.4.5", diff --git a/src/lib/mirror.ts b/src/lib/mirror.ts new file mode 100644 index 0000000..aa81595 --- /dev/null +++ b/src/lib/mirror.ts @@ -0,0 +1,223 @@ +import { + GetObjectCommand, + HeadObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { Upload } from '@aws-sdk/lib-storage'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { Readable } from 'node:stream'; + +import { Asset, type StorageConfig } from './types'; + +/** + * Espelho das releases num bucket S3/R2. + * + * Existe por um motivo só: as URLs do GitHub carregam o `response-content- + * disposition` DENTRO da assinatura, então não há como servir o mesmo binário + * com um nome de arquivo próprio redirecionando para lá. Com o arquivo num + * bucket próprio, uma presigned URL resolve isso sem que nenhum byte passe por + * este servidor. + * + * É uma cópia por release, não por download. + */ + +/** Validade da URL assinada. Curta: ela é gerada no clique do usuário. */ +const SIGNED_URL_TTL_SECONDS = 15 * 60; + +/** Formato aceito para a etiqueta: opaca, sem espaço nem caractere de path. */ +const TAG_PATTERN = /^[A-Za-z0-9_-]{16,64}$/; + +export const isValidTag = (value: string) => TAG_PATTERN.test(value); + +/** + * Prefixo da etiqueta no nome do arquivo. Configurável porque o cliente que lê + * esse nome precisa combinar com ele, e o par pode ser rotacionado sem release. + */ +export const tagPrefix = () => process.env.INSTALLER_TAG_PREFIX ?? 'build-'; + +/** + * `Meu.App.-.v1.2.3.exe` + etiqueta → `Meu.App.-.v1.2.3 [].exe` + */ +export const buildTaggedFilename = (assetName: string, tag: string) => { + const safeName = assetName.replace(/["\r\n]/g, ''); + const extensionAt = safeName.lastIndexOf('.'); + + const base = extensionAt === -1 ? safeName : safeName.slice(0, extensionAt); + const extension = extensionAt === -1 ? '' : safeName.slice(extensionAt); + + return `${base} [${tagPrefix()}${tag}]${extension}`; +}; + +/** + * O repositório entra na chave para que múltiplos deploys deste serviço, cada um + * lendo um repositório de releases diferente, possam dividir o mesmo bucket sem + * risco de um sobrescrever o outro. + */ +export const buildObjectKey = ( + prefix: string | undefined, + repository: string, + version: string, + assetName: string, +) => `${prefix ?? 'releases'}/${repository}/${version}/${assetName}`; + +export default class Mirror { + private readonly config: StorageConfig; + + private readonly repository: string; + + private readonly githubToken?: string; + + private readonly s3?: S3Client; + + /** Chaves já confirmadas no bucket — evita um HeadObject por request. */ + private readonly mirrored = new Set(); + + /** Cópias em andamento, para duas requisições não subirem o mesmo arquivo. */ + private readonly inFlight = new Set(); + + constructor( + config: StorageConfig, + repository?: string, + githubToken?: string, + ) { + this.config = config; + this.repository = repository ?? 'unknown'; + this.githubToken = githubToken; + + if (!this.isConfigured) return; + + this.s3 = new S3Client({ + region: config.region ?? 'auto', + endpoint: config.endpoint, + credentials: { + accessKeyId: config.accessKeyId!, + secretAccessKey: config.secretAccessKey!, + }, + }); + } + + /** + * Sem bucket configurado o espelho fica inteiro desligado e as rotas caem no + * redirect de sempre. Permite subir o código antes de existir a infra. + */ + get isConfigured() { + const { bucket, accessKeyId, secretAccessKey } = this.config; + return Boolean(bucket && accessKeyId && secretAccessKey); + } + + private objectKey(version: string, assetName: string) { + return buildObjectKey( + this.config.prefix, + this.repository, + version, + assetName, + ); + } + + private async exists(key: string) { + if (this.mirrored.has(key)) return true; + + try { + await this.s3!.send( + new HeadObjectCommand({ Bucket: this.config.bucket!, Key: key }), + ); + this.mirrored.add(key); + return true; + } catch { + return false; + } + } + + /** + * Copia o asset para o bucket se ainda não estiver lá. Chamada sem await + * pelas rotas: a primeira pessoa a baixar depois de um release novo cai no + * redirect normal (sem etiqueta) enquanto a cópia acontece. + */ + async ensureMirrored(version: string, asset: Asset): Promise { + if (!this.isConfigured) return; + + const key = this.objectKey(version, asset.name); + + // Reserva ANTES de qualquer await. Se o `exists()` viesse primeiro, todas as + // requisições concorrentes cederiam o event loop no HeadObject e passariam + // pelo guard juntas — cada uma baixando ~150 MB do GitHub e abrindo um + // upload multipart. No dia de uma release isso derruba o dyno, que é o mesmo + // que serve o canal de update de toda a base instalada. + if (this.inFlight.has(key)) return; + + this.inFlight.add(key); + + try { + if (await this.exists(key)) return; + + const headers: Record = { + Accept: 'application/octet-stream', + }; + + if (this.githubToken) { + headers.Authorization = `token ${this.githubToken}`; + } + + const response = await fetch( + this.githubToken ? asset.api_url : asset.url, + { headers }, + ); + + if (!response.ok || !response.body) { + // Sem cancelar, cada falha repetida (rate limit, token expirado) deixa + // uma conexão pendurada. + await response.body?.cancel(); + throw new Error(`GitHub respondeu ${response.status}`); + } + + await new Upload({ + client: this.s3!, + params: { + Bucket: this.config.bucket!, + Key: key, + Body: Readable.fromWeb(response.body as never), + ContentType: asset.content_type || 'application/octet-stream', + }, + }).done(); + + this.mirrored.add(key); + console.log(`[mirror] espelhado ${key}`); + } catch (err) { + console.error(`[mirror] falha ao espelhar ${key}:`, err); + } finally { + this.inFlight.delete(key); + } + } + + /** + * URL assinada que força o download com a etiqueta no nome do arquivo. + * Devolve null quando o espelho não está pronto — quem chama cai no redirect + * normal e o usuário recebe o mesmo binário, só sem a etiqueta. + * + * A etiqueta é opaca para este serviço e pode carregar dado sensível de quem + * chamou: NÃO logar. + */ + async getTaggedUrl( + version: string, + asset: Asset, + tag: string, + ): Promise { + if (!this.isConfigured || !isValidTag(tag)) return null; + + const key = this.objectKey(version, asset.name); + + if (!(await this.exists(key))) return null; + + const filename = buildTaggedFilename(asset.name, tag); + + return getSignedUrl( + this.s3!, + new GetObjectCommand({ + Bucket: this.config.bucket!, + Key: key, + ResponseContentDisposition: `attachment; filename="${filename}"`, + }), + { expiresIn: SIGNED_URL_TTL_SECONDS }, + ); + } +} diff --git a/src/lib/routes.ts b/src/lib/routes.ts index 9943438..0eb10c8 100644 --- a/src/lib/routes.ts +++ b/src/lib/routes.ts @@ -13,6 +13,7 @@ import checkAlias from './aliases'; import prepareView from './view'; import { formatDistanceToNow } from 'date-fns'; +import Mirror, { isValidTag } from './mirror'; import { Asset, type Config } from './types'; import type Cache from './cache'; @@ -27,8 +28,61 @@ export default ({ cache, config }: { cache: Cache; config: Config }) => { const shouldProxyPrivateDownload = token && typeof token === 'string' && token.length > 0; + const mirror = new Mirror(config.storage ?? {}, config.repository, token); + + /** + * Mantém o espelho aquecido usando o tráfego que já existe — o updater de + * cada cliente bate aqui a cada 3 min, então uma release nova costuma estar + * espelhada bem antes de a primeira pessoa clicar em baixar. + */ + const loadCacheAndWarmMirror = async () => { + const latest = await loadCache(); + const exe = latest.platforms?.exe; + + // `version` é declarada como SemVer no cache mas recebe a string do + // tag_name; String() reconcilia sem mexer no tipo existente. + if (exe && latest.version) { + void mirror.ensureMirrored(String(latest.version), exe); + } + + return latest; + }; + + /** + * Instalador com uma etiqueta opaca no nome do arquivo, servido por presigned + * URL do bucket próprio. Serve para o cliente correlacionar o download que + * originou aquela instalação. + * + * Devolve false quando não dá (sem espelho, etiqueta inválida, release ainda + * não copiada) e quem chama segue para o redirect normal — o usuário baixa o + * mesmo binário, só sem a etiqueta. + * + * A etiqueta é opaca aqui e pode carregar dado sensível de quem chamou: NÃO + * logar em hipótese alguma. + */ + const tryTaggedDownload = async ( + tag: unknown, + version: unknown, + asset: Asset, + res: ServerResponse, + ) => { + if (typeof tag !== 'string' || !isValidTag(tag)) { + return false; + } + + if (!version) return false; + + const location = await mirror.getTaggedUrl(String(version), asset, tag); + + if (!location) return false; + + res.writeHead(302, { Location: location }); + res.end(); + + return true; + }; + // Helpers - // TODO: armazenar no S3 e gerar link de download de lá const proxyPrivateDownload = ( asset: Asset, req: IncomingMessage, @@ -64,13 +118,24 @@ export default ({ cache, config }: { cache: Cache; config: Config }) => { } // Get the latest version from the cache - const { platforms } = await loadCache(); + const latest = await loadCacheAndWarmMirror(); + const { platforms } = latest; if (!platform || !platforms?.[platform]) { send(res, 404, 'No download available for your platform!'); return; } + // Só no download humano: uma atualização do Squirrel não pode ganhar nome + // diferente, senão o protocolo deixa de reconhecer o pacote. + if ( + platform === 'exe' && + !isUpdate && + (await tryTaggedDownload(params?.t, latest.version, platforms.exe, res)) + ) { + return; + } + if (shouldProxyPrivateDownload) { proxyPrivateDownload(platforms[platform], req, res); return; @@ -102,7 +167,7 @@ export default ({ cache, config }: { cache: Cache; config: Config }) => { } // Get the latest version from the cache - const latest = await loadCache(); + const latest = await loadCacheAndWarmMirror(); // Check platform for appropiate aliases try { @@ -114,13 +179,26 @@ export default ({ cache, config }: { cache: Cache; config: Config }) => { return; } - console.log(latest.platforms); - if (!latest.platforms?.[platform]) { send(res, 404, 'No download available for your platform'); return; } + // Só no download humano: uma atualização do Squirrel não pode ganhar nome + // diferente, senão o protocolo deixa de reconhecer o pacote. + if ( + platform === 'exe' && + !isUpdate && + (await tryTaggedDownload( + params?.t, + latest.version, + latest.platforms.exe, + res, + )) + ) { + return; + } + if (token && typeof token === 'string' && token.length > 0) { proxyPrivateDownload(latest.platforms[platform], request, res); return; @@ -158,7 +236,10 @@ export default ({ cache, config }: { cache: Cache; config: Config }) => { } // Get the latest version from the cache - const latest = await loadCache(); + // Aquece o espelho aqui também: esta é a rota que o updater de cada cliente + // bate a cada 3 min, e é ela que faz uma release nova estar copiada antes + // de a primeira pessoa clicar em baixar. + const latest = await loadCacheAndWarmMirror(); if (!latest.platforms?.[platform]) { res.statusCode = 204; diff --git a/src/lib/server.ts b/src/lib/server.ts index 2c9daf8..aab1ffc 100644 --- a/src/lib/server.ts +++ b/src/lib/server.ts @@ -12,6 +12,17 @@ const { const url = VERCEL_URL ?? PRIVATE_BASE_URL; +// Sem estas variáveis o espelho fica desligado e as rotas de download seguem +// redirecionando para o GitHub como sempre. +const storage = { + bucket: process.env.BUCKET_NAME_CLOUDFLARE_R2, + accessKeyId: process.env.AWS_ACCESS_KEY_ID_CLOUDFLARE_R2, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY_CLOUDFLARE_R2, + region: process.env.AWS_DEFAULT_REGION_CLOUDFLARE_R2, + endpoint: process.env.AWS_ENDPOINT_CLOUDFLARE_R2, + prefix: process.env.RELEASES_MIRROR_PREFIX, +}; + export default nutela({ interval, account, @@ -19,4 +30,5 @@ export default nutela({ pre, token, url, + storage, }); diff --git a/src/lib/types.ts b/src/lib/types.ts index 22cbf2d..1c98666 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,3 +1,17 @@ +/** + * Bucket S3/R2 onde as releases são espelhadas, para servir o instalador com um + * nome de arquivo próprio. Opcional: sem isso o espelho fica desligado e as + * rotas seguem redirecionando para o GitHub como sempre. + */ +export type StorageConfig = { + bucket?: string; + accessKeyId?: string; + secretAccessKey?: string; + region?: string; + endpoint?: string; + prefix?: string; +}; + export type Config = { interval?: string; account?: string; @@ -5,6 +19,7 @@ export type Config = { pre?: string; token?: string; url?: string; + storage?: StorageConfig; }; export type Asset = { diff --git a/yarn.lock b/yarn.lock index f4c89ec..96d7834 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10,6 +10,249 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" +"@aws-sdk/checksums@^3.1000.21": + version "3.1000.21" + resolved "https://registry.yarnpkg.com/@aws-sdk/checksums/-/checksums-3.1000.21.tgz#2be054036892f93c25c84d6ec70bb58127aa2161" + integrity sha512-AcYTunavv8dqm9u2pbq/gIlFERUo+jQDKKLZpYOgMLLytoAJ/qoyuhWj97FB7UzpMFVJNzMEUylzKK/s/05org== + dependencies: + "@aws-sdk/core" "^3.977.1" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.8" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/client-s3@3.1096.0": + version "3.1096.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-s3/-/client-s3-3.1096.0.tgz#0f66e5f6bf5a4d1f8a2837d13847e3f9de4f308a" + integrity sha512-sEx7KAEtkp1UqOoWQv2baM1rreClZG7o9YE8EfPvYpPBvGad1fQRG3sMHrOPMkIdZ9VjGRl25GiaG1MSeie2Mw== + dependencies: + "@aws-sdk/checksums" "^3.1000.21" + "@aws-sdk/core" "^3.977.1" + "@aws-sdk/credential-provider-node" "^3.972.73" + "@aws-sdk/middleware-sdk-s3" "^3.972.67" + "@aws-sdk/signature-v4-multi-region" "^3.996.42" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.8" + "@smithy/fetch-http-handler" "^5.6.10" + "@smithy/node-http-handler" "^4.9.10" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/core@^3.977.1": + version "3.977.1" + resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.977.1.tgz#2831daa053f3a50ad426898e6920f08f807b97ff" + integrity sha512-KVtQRtc00ES/y+Sc3vYXeP6pCIcNlBJCZOwvqSy8ZpVGmbM5+IG+AfhuTKQ2oXmIVqZJewaGMMpzPkywC6xg0w== + dependencies: + "@aws-sdk/types" "^3.974.2" + "@aws-sdk/xml-builder" "^3.972.37" + "@aws/lambda-invoke-store" "^0.3.0" + "@smithy/core" "^3.29.8" + "@smithy/signature-v4" "^5.6.9" + "@smithy/types" "^4.16.1" + bowser "^2.11.0" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-env@^3.972.62": + version "3.972.62" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.62.tgz#24f6f62ff31df013963a700ab111c6e6d059944e" + integrity sha512-BkDrk2cNjed31IKin/Oksb2ziF+gfuyRskFVuT4EU9Mep7M8Y/d8DJG4+anHme4Vuse7CwaEscwEfGyR6mzBhQ== + dependencies: + "@aws-sdk/core" "^3.977.1" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.8" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-http@^3.972.64": + version "3.972.64" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.64.tgz#55b5a410542ad091e08a4eb492d4a7c564606d22" + integrity sha512-Wj1FGK2IxY5EccQCvH+niTYhIvDoDujJf2CpRRgS3NpYNEgiFNVItNbJYQjINRlu7fG7jSsXkKV0UWKriEplrw== + dependencies: + "@aws-sdk/core" "^3.977.1" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.8" + "@smithy/fetch-http-handler" "^5.6.10" + "@smithy/node-http-handler" "^4.9.10" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-ini@^3.973.7": + version "3.973.7" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.7.tgz#12f8319cdb3814dde93ae9d9ee760ae48fdfe5db" + integrity sha512-2CefB8cCxDu52P24B8Ay93/cTT199bcSvNHQ8e2f4BjSCF83yErBnTIZEBo0VeIgCfmw+PJKFUXnlQWxm2dkug== + dependencies: + "@aws-sdk/core" "^3.977.1" + "@aws-sdk/credential-provider-env" "^3.972.62" + "@aws-sdk/credential-provider-http" "^3.972.64" + "@aws-sdk/credential-provider-login" "^3.972.69" + "@aws-sdk/credential-provider-process" "^3.972.62" + "@aws-sdk/credential-provider-sso" "^3.973.6" + "@aws-sdk/credential-provider-web-identity" "^3.972.68" + "@aws-sdk/nested-clients" "^3.997.36" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.8" + "@smithy/credential-provider-imds" "^4.4.13" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-login@^3.972.69": + version "3.972.69" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.69.tgz#943ea85a247063511ec8b3d324e880b7162ca2db" + integrity sha512-gM3j0Ie9+FoLNTYODY+QWbg3vCRBc7mR9cRdntxTMkFYIrwfRmuucfavP6HNBlYSuaYww54TNJGej4GFgoPZAg== + dependencies: + "@aws-sdk/core" "^3.977.1" + "@aws-sdk/nested-clients" "^3.997.36" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.8" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-node@^3.972.73": + version "3.972.73" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.73.tgz#b0184a78073b6b3a0946f8e22c9aa1a877a0f2ac" + integrity sha512-VTzdbf8Ukjdb9yUubZzRI678CWZvKovhE8Nv3qihwhC187sRMGls+r9N8Wuht5q1xjKx2nmpS48ar8ppupjkCA== + dependencies: + "@aws-sdk/credential-provider-env" "^3.972.62" + "@aws-sdk/credential-provider-http" "^3.972.64" + "@aws-sdk/credential-provider-ini" "^3.973.7" + "@aws-sdk/credential-provider-process" "^3.972.62" + "@aws-sdk/credential-provider-sso" "^3.973.6" + "@aws-sdk/credential-provider-web-identity" "^3.972.68" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.8" + "@smithy/credential-provider-imds" "^4.4.13" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-process@^3.972.62": + version "3.972.62" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.62.tgz#09d61aa0f0ef20a1ed5c6f729ea89ee3c9758ed9" + integrity sha512-zXYU9UWNL66gtMgNLhmxlrvEokuI7r6G2q7FRGu41Bya4iS30JLelUipJX9SV4zhyCPWJhI9Li54R1d9H8Tq6A== + dependencies: + "@aws-sdk/core" "^3.977.1" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.8" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-sso@^3.973.6": + version "3.973.6" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.6.tgz#f395a91ae737a0ad3a8c276f1bfa01b2a241ab3e" + integrity sha512-DobZggy3K49xdCpjeyMou0FQhkoYbluVGNydL6D+lcxF8GoAsttFX0xnH5GmiQ89We5dB6TRpW+CD/VowBH6HQ== + dependencies: + "@aws-sdk/core" "^3.977.1" + "@aws-sdk/nested-clients" "^3.997.36" + "@aws-sdk/token-providers" "3.1096.0" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.8" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-web-identity@^3.972.68": + version "3.972.68" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.68.tgz#8860040273d92b2d5f48b94ac26a1c6a48c056c2" + integrity sha512-bq+yTt+uWJx60VVp/OIAX5xqUAu/K2Uc3eknWnWl+KtfcU2CQe0uNw6lySrn2t5GKHq7jsV0Z63HiBGVtzr/lg== + dependencies: + "@aws-sdk/core" "^3.977.1" + "@aws-sdk/nested-clients" "^3.997.36" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.8" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/lib-storage@3.1096.0": + version "3.1096.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/lib-storage/-/lib-storage-3.1096.0.tgz#1791ff3f758c634b4a6fe710f2bf456b181cd992" + integrity sha512-A9ZoQFUawEO2l7jVoBRtDbIJ3hPBnxMRy3oIGQREOsMfwQFvoiyyV0f1Fl5CuIfoovv508TuW+Pe6WZIfh7NOg== + dependencies: + "@smithy/core" "^3.29.8" + "@smithy/types" "^4.16.1" + buffer "5.6.0" + events "3.3.0" + stream-browserify "3.0.0" + tslib "^2.6.2" + +"@aws-sdk/middleware-sdk-s3@^3.972.67": + version "3.972.67" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.67.tgz#b40e10b111663bfda81563c4980116029fe07cdf" + integrity sha512-aB1ahF9z4J5r8YK1QodwNX/P8XSKBFtnjf7h72XCs0BP3rtThOiXeA3Lm+Z+8tiN9Iwl5otsGeHaXmQoQ5MVfA== + dependencies: + "@aws-sdk/core" "^3.977.1" + "@aws-sdk/signature-v4-multi-region" "^3.996.42" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.8" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/nested-clients@^3.997.36": + version "3.997.36" + resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.997.36.tgz#1e0f641d1e1190117496cf2dd584e1e3bb48d20f" + integrity sha512-b71Suv7L+DnhM0MsQHU4WO42I32kxLZi96PbVhZbxMYIoKnEZz3v+LSrG8fupAoA4cBSshCk1Dl/PeRz49qUSg== + dependencies: + "@aws-sdk/core" "^3.977.1" + "@aws-sdk/signature-v4-multi-region" "^3.996.42" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.8" + "@smithy/fetch-http-handler" "^5.6.10" + "@smithy/node-http-handler" "^4.9.10" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/s3-request-presigner@3.1096.0": + version "3.1096.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1096.0.tgz#06686ad92dcb8ea0b2150ba5078ba5a5e6a0b4b0" + integrity sha512-vyWoSQwoWLAr/rB06vstzOXLWFCrRvyTz5HQC0jwZ51oEe+GKCIvF671AHicUfiwiVjNZ257VponbCWmrzj0Kw== + dependencies: + "@aws-sdk/core" "^3.977.1" + "@aws-sdk/signature-v4-multi-region" "^3.996.42" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.8" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/signature-v4-multi-region@^3.996.42": + version "3.996.42" + resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.42.tgz#855bbea16f0ddbbafd99106793f43bf1001b4e71" + integrity sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q== + dependencies: + "@aws-sdk/types" "^3.974.2" + "@smithy/signature-v4" "^5.6.9" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/token-providers@3.1096.0": + version "3.1096.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1096.0.tgz#0aa251af60554fd58796ec98a69c7bcc48eeccc3" + integrity sha512-hdUS2hDppy3vkWeFl5y86RLNU6OWH2mQB09yOSsRefwhhGTSFPkaZvfLDD/9vFcvMzlr8QFQFw3fw2FtrurVQA== + dependencies: + "@aws-sdk/core" "^3.977.1" + "@aws-sdk/nested-clients" "^3.997.36" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.8" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/types@^3.974.2": + version "3.974.2" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.974.2.tgz#05e7ccac417735e0786d430d2d5cc139047a08da" + integrity sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws-sdk/xml-builder@^3.972.37": + version "3.972.37" + resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz#669363721702d46a500c6ea485a44591e511f280" + integrity sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@aws/lambda-invoke-store@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz#708802d987f8e17bdf4af4de1031660ce1cdd65f" + integrity sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ== + "@babel/code-frame@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8" @@ -687,6 +930,57 @@ dependencies: "@sinonjs/commons" "^3.0.0" +"@smithy/core@^3.29.8", "@smithy/core@^3.31.0": + version "3.31.0" + resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.31.0.tgz#b35b5992ddeaa8044267cbb76e290181b4943160" + integrity sha512-sylYk2l9d7CmRv8ts8p0SDQUr3VO+HMeS1nrjL6+UtbO8ktJHTOeQ1McX+aAyvGGccp5aZX9eNtdcXrSwzoZaw== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@smithy/credential-provider-imds@^4.4.13": + version "4.4.15" + resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.15.tgz#405202db182c4a06a6cc9bfc0f281d059544e450" + integrity sha512-xYVGrisQqTJWhOnScUhbx8s9H63TMtoxzuUoxG6mP8J+B/YbX3vZxVsgV0xDf43abJnJP0fjP7BkQh7OESwuRA== + dependencies: + "@smithy/core" "^3.31.0" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@smithy/fetch-http-handler@^5.6.10": + version "5.6.12" + resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.12.tgz#cedde1c9c3b01e0d2089480908c9ef952d273ebb" + integrity sha512-OpQgP6IGH4j0NJ2zjfYZLjQL85ai+Wi/q51EmZJovXsEwKSvu89qiXUq77Q6EmwZ/hSl7fKpn2Z9mhiDN6OM+Q== + dependencies: + "@smithy/core" "^3.31.0" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@smithy/node-http-handler@^4.9.10": + version "4.9.12" + resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.9.12.tgz#da7ede54a652f47aa3ac771d178fc6424ad3ca77" + integrity sha512-dWW5KRt4mnEvjNzbGqGeCuAvgum85Y9ZoyuMQqcTEfapndyVJ1k9BEHK7kdXJZ32enyRmmwcFjMwlB/KgLKI3Q== + dependencies: + "@smithy/core" "^3.31.0" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@smithy/signature-v4@^5.6.9": + version "5.6.11" + resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.6.11.tgz#1e71be9713615c603ebe25471ad75febaa9a12d7" + integrity sha512-7HsspeiNCZvZHEJ22vV5L/QYuJdTyJvPJvMrYD3AgkM3IJB0pkln4jkjPvtpTWRMkHXbO8WKwNjoVdVlBFwHmw== + dependencies: + "@smithy/core" "^3.31.0" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@smithy/types@^4.16.1": + version "4.16.1" + resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.16.1.tgz#19e199c234829a51c085caf63f0bb17bb80187e4" + integrity sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg== + dependencies: + tslib "^2.6.2" + "@tsconfig/node10@^1.0.7": version "1.0.11" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2" @@ -1272,6 +1566,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= +base64-js@^1.0.2: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + binary-extensions@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" @@ -1284,6 +1583,11 @@ biskviit@1.0.1: dependencies: psl "^1.1.7" +bowser@^2.11.0: + version "2.14.1" + resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.14.1.tgz#4ea39bf31e305184522d7ad7bfd91389e4f0cb79" + integrity sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -1335,6 +1639,14 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== +buffer@5.6.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.6.0.tgz#a31749dc7d81d84db08abf937b6b8c4033f62786" + integrity sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw== + dependencies: + base64-js "^1.0.2" + ieee754 "^1.1.4" + bytes@3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" @@ -2086,6 +2398,11 @@ eventemitter3@^5.0.1: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== +events@3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + execa@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" @@ -2549,6 +2866,11 @@ iconv-lite@0.4.24, iconv-lite@~0.4.13: dependencies: safer-buffer ">= 2.1.2 < 3" +ieee754@^1.1.4: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + ignore@^5.2.0, ignore@^5.2.4, ignore@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef" @@ -2588,7 +2910,7 @@ inherits@2: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= -inherits@2.0.4, inherits@^2.0.1, inherits@~2.0.1, inherits@~2.0.3: +inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -3834,6 +4156,15 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== +readable-stream@^3.5.0: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readable-stream@~1.0.31: version "1.0.34" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" @@ -3987,6 +4318,11 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== +safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + safe-regex-test@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.3.tgz#a5b4c0f06e0ab50ea2c395c14d8371232924c377" @@ -4149,6 +4485,14 @@ statuses@2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== +stream-browserify@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f" + integrity sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA== + dependencies: + inherits "~2.0.4" + readable-stream "^3.5.0" + string-argv@~0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" @@ -4208,6 +4552,13 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + string_decoder@~0.10.x: version "0.10.31" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" @@ -4573,7 +4924,7 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -util-deprecate@~1.0.1: +util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== From 14dee2371bdf2c29ba9eae78f60d4df4ac928c60 Mon Sep 17 00:00:00 2001 From: Jean Lucas de Carvalho Date: Tue, 28 Jul 2026 18:16:06 -0300 Subject: [PATCH 2/2] test: cover the release mirror against a stubbed S3 client The first version of these tests only exercised pure helpers and the mirror-disabled path, so several mutations survived: dropping `ResponseContentDisposition`, dropping tag validation, widening the presigned URL lifetime, turning the `&&` in `isConfigured` into `||`, and inverting the concurrency guard. Now covers the tagged filename, the object key, the short signature lifetime, the fallback when an asset has not been copied yet, 20 concurrent calls resulting in a single download, and GitHub/upload failures not taking the process down. All five mutations above now fail. Co-Authored-By: Claude Opus 5 --- test/mirror-storage.test.ts | 228 ++++++++++++++++++++++++++++++++++++ test/mirror.test.ts | 109 +++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 test/mirror-storage.test.ts create mode 100644 test/mirror.test.ts diff --git a/test/mirror-storage.test.ts b/test/mirror-storage.test.ts new file mode 100644 index 0000000..05da0c6 --- /dev/null +++ b/test/mirror-storage.test.ts @@ -0,0 +1,228 @@ +/* global describe, it, expect, jest, beforeEach, afterEach */ +import { Readable } from 'node:stream'; +import Mirror from '../src/lib/mirror'; +import { Asset } from '../src/lib/types'; + +const mockSend = jest.fn(); +const mockUploadDone = jest.fn(); +const mockGetSignedUrl = jest.fn(); +const mockUploadCtor = jest.fn(); + +jest.mock('@aws-sdk/client-s3', () => ({ + S3Client: jest.fn().mockImplementation(() => ({ send: mockSend })), + HeadObjectCommand: jest.fn().mockImplementation(input => ({ + name: 'HeadObject', + input, + })), + GetObjectCommand: jest.fn().mockImplementation(input => ({ + name: 'GetObject', + input, + })), +})); + +jest.mock('@aws-sdk/lib-storage', () => ({ + Upload: jest.fn().mockImplementation(params => { + mockUploadCtor(params); + return { done: mockUploadDone }; + }), +})); + +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: (...args: unknown[]) => mockGetSignedUrl(...args), +})); + +const STORAGE = { + bucket: 'releases-bucket', + accessKeyId: 'key', + secretAccessKey: 'secret', + endpoint: 'https://r2.example', +}; + +const REPO = 'owner/app-releases'; +const VERSION = 'v1.2.3'; +const TOKEN = '4f3Aq-_ZxY7bK1nP2sTuVw'; + +const asset: Asset = { + name: 'Meu.App.-.v1.2.3.exe', + api_url: 'https://api.github.com/repos/x/y/releases/assets/1', + url: 'https://github.com/x/y/releases/download/v1.2.3/setup.exe', + content_type: 'application/octet-stream', + size: 150, +}; + +const EXPECTED_KEY = `releases/${REPO}/${VERSION}/${asset.name}`; + +/** Um HeadObject que rejeita = objeto ainda não espelhado. */ +const notMirrored = () => mockSend.mockRejectedValue(new Error('NotFound')); +const alreadyMirrored = () => mockSend.mockResolvedValue({}); + +function fakeGithubResponse() { + return { + ok: true, + status: 200, + body: Readable.toWeb(Readable.from(['conteudo-do-binario'])), + }; +} + +describe('Mirror com storage configurado', () => { + let mirror: Mirror; + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mirror = new Mirror(STORAGE, REPO, 'github-token'); + fetchSpy = jest + .spyOn(global, 'fetch') + .mockImplementation(async () => fakeGithubResponse() as never); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('getTaggedUrl', () => { + it('assina a URL forçando o filename com a etiqueta', async () => { + alreadyMirrored(); + mockGetSignedUrl.mockResolvedValue('https://r2.example/assinada'); + + const url = await mirror.getTaggedUrl(VERSION, asset, TOKEN); + + expect(url).toBe('https://r2.example/assinada'); + + const [, command] = mockGetSignedUrl.mock.calls[0]; + expect(command.input).toMatchObject({ + Bucket: STORAGE.bucket, + Key: EXPECTED_KEY, + ResponseContentDisposition: `attachment; filename="Meu.App.-.v1.2.3 [build-${TOKEN}].exe"`, + }); + }); + + it('assina com validade curta', async () => { + alreadyMirrored(); + mockGetSignedUrl.mockResolvedValue('https://r2.example/assinada'); + + await mirror.getTaggedUrl(VERSION, asset, TOKEN); + + const [, , options] = mockGetSignedUrl.mock.calls[0]; + expect(options.expiresIn).toBeLessThanOrEqual(15 * 60); + }); + + it('recusa etiqueta fora do formato, sem chegar a assinar', async () => { + alreadyMirrored(); + + const url = await mirror.getTaggedUrl(VERSION, asset, 'curto'); + + expect(url).toBeNull(); + expect(mockGetSignedUrl).not.toHaveBeenCalled(); + }); + + it('devolve null quando a release ainda não foi espelhada', async () => { + notMirrored(); + + const url = await mirror.getTaggedUrl(VERSION, asset, TOKEN); + + // Quem chama cai no redirect normal: o usuário baixa o mesmo binário, + // só sem a etiqueta. + expect(url).toBeNull(); + expect(mockGetSignedUrl).not.toHaveBeenCalled(); + }); + }); + + describe('ensureMirrored', () => { + it('copia para a chave que separa repositório e versão', async () => { + notMirrored(); + mockUploadDone.mockResolvedValue({}); + + await mirror.ensureMirrored(VERSION, asset); + + expect(mockUploadCtor).toHaveBeenCalledTimes(1); + expect(mockUploadCtor.mock.calls[0][0].params).toMatchObject({ + Bucket: STORAGE.bucket, + Key: EXPECTED_KEY, + }); + }); + + it('não copia de novo o que já está no bucket', async () => { + alreadyMirrored(); + + await mirror.ensureMirrored(VERSION, asset); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(mockUploadCtor).not.toHaveBeenCalled(); + }); + + it('sob concorrência, baixa e sobe uma única vez', async () => { + // Sem a reserva antes do await, todas as chamadas cediam o event loop no + // HeadObject e passavam juntas — cada uma baixando o binário inteiro. + notMirrored(); + mockUploadDone.mockResolvedValue({}); + + await Promise.all( + Array.from({ length: 20 }, () => mirror.ensureMirrored(VERSION, asset)), + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(mockUploadCtor).toHaveBeenCalledTimes(1); + }); + + it('usa a api_url autenticada quando há credencial do GitHub', async () => { + notMirrored(); + mockUploadDone.mockResolvedValue({}); + + await mirror.ensureMirrored(VERSION, asset); + + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe(asset.api_url); + expect(init.headers.Authorization).toBe('token github-token'); + }); + + it('não deixa a falha do GitHub derrubar o processo', async () => { + notMirrored(); + fetchSpy.mockResolvedValue({ + ok: false, + status: 404, + body: null, + } as never); + + await expect( + mirror.ensureMirrored(VERSION, asset), + ).resolves.toBeUndefined(); + expect(mockUploadCtor).not.toHaveBeenCalled(); + }); + + it('não deixa a falha do upload derrubar o processo', async () => { + notMirrored(); + mockUploadDone.mockRejectedValue(new Error('R2 fora')); + + await expect( + mirror.ensureMirrored(VERSION, asset), + ).resolves.toBeUndefined(); + }); + + it('libera a reserva depois de falhar, para a próxima tentativa', async () => { + notMirrored(); + mockUploadDone.mockRejectedValueOnce(new Error('R2 fora')); + await mirror.ensureMirrored(VERSION, asset); + + mockUploadDone.mockResolvedValue({}); + await mirror.ensureMirrored(VERSION, asset); + + expect(mockUploadCtor).toHaveBeenCalledTimes(2); + }); + }); +}); + +describe('isConfigured', () => { + it.each([ + ['sem bucket', { ...STORAGE, bucket: undefined }], + ['sem access key', { ...STORAGE, accessKeyId: undefined }], + ['sem secret', { ...STORAGE, secretAccessKey: undefined }], + ])('fica desligado %s', (_label, storage) => { + // O README promete que as três são obrigatórias para ligar o espelho. + expect(new Mirror(storage, REPO).isConfigured).toBe(false); + }); + + it('liga com as três presentes', () => { + expect(new Mirror(STORAGE, REPO).isConfigured).toBe(true); + }); +}); diff --git a/test/mirror.test.ts b/test/mirror.test.ts new file mode 100644 index 0000000..30ef40c --- /dev/null +++ b/test/mirror.test.ts @@ -0,0 +1,109 @@ +/* global describe, it, expect */ +import Mirror, { + buildObjectKey, + buildTaggedFilename, + isValidTag, +} from '../src/lib/mirror'; +import { Asset } from '../src/lib/types'; + +// O GitHub troca espaços por pontos no nome do asset ao publicar a release, +// então é assim que ele chega aqui. +const asset: Asset = { + name: 'Meu.App.-.v1.2.3.exe', + api_url: 'https://api.github.com/repos/x/y/releases/assets/1', + url: 'https://github.com/x/y/releases/download/v1.2.3/setup.exe', + content_type: 'application/octet-stream', + size: 150, +}; + +describe('buildTaggedFilename', () => { + it('insere a etiqueta antes da extensão', () => { + expect(buildTaggedFilename(asset.name, 'abc123')).toBe( + 'Meu.App.-.v1.2.3 [build-abc123].exe', + ); + }); + + it('funciona com qualquer nome de asset', () => { + expect(buildTaggedFilename('Outro.App.-.v1.2.3.exe', 'abc123')).toBe( + 'Outro.App.-.v1.2.3 [build-abc123].exe', + ); + }); + + it('lida com nome sem extensão', () => { + expect(buildTaggedFilename('Setup', 'abc')).toBe('Setup [build-abc]'); + }); + + it('remove aspas e quebras de linha, que quebrariam o header', () => { + expect(buildTaggedFilename('Se"tup\r\n.exe', 'abc')).toBe( + 'Setup [build-abc].exe', + ); + }); +}); + +describe('buildObjectKey', () => { + it('separa deploys distintos que leem repositorios diferentes', () => { + const first = buildObjectKey( + undefined, + 'owner/app-releases', + 'v1.2.3', + 'Meu.App.-.v1.2.3.exe', + ); + const second = buildObjectKey( + undefined, + 'owner/other-releases', + 'v1.2.3', + 'Outro.App.-.v1.2.3.exe', + ); + + expect(first).toBe( + 'releases/owner/app-releases/v1.2.3/Meu.App.-.v1.2.3.exe', + ); + expect(second).toBe( + 'releases/owner/other-releases/v1.2.3/Outro.App.-.v1.2.3.exe', + ); + expect(first).not.toBe(second); + }); + + it('respeita o prefixo configurado', () => { + expect(buildObjectKey('installers', 'repo', 'v1', 'a.exe')).toBe( + 'installers/repo/v1/a.exe', + ); + }); +}); + +describe('isValidTag', () => { + it('aceita base64url no tamanho esperado', () => { + expect(isValidTag('4f3Aq-_ZxY7bK1nP2sTuVw')).toBe(true); + }); + + it('recusa etiqueta curta, vazia ou com caractere fora do alfabeto', () => { + expect(isValidTag('')).toBe(false); + expect(isValidTag('curto')).toBe(false); + expect(isValidTag('tem espaco no meio dele')).toBe(false); + expect(isValidTag('../../etc/passwd/aaaaaaaaaa')).toBe(false); + }); +}); + +describe('Mirror sem storage configurado', () => { + const mirror = new Mirror({}, 'owner/app-releases'); + + it('fica desligado', () => { + expect(mirror.isConfigured).toBe(false); + }); + + it('não gera URL, para o chamador cair no redirect normal', async () => { + const url = await mirror.getTaggedUrl( + 'v1.2.3', + asset, + '4f3Aq-_ZxY7bK1nP2sTuVw', + ); + + expect(url).toBeNull(); + }); + + it('não tenta espelhar', async () => { + await expect( + mirror.ensureMirrored('v1.2.3', asset), + ).resolves.toBeUndefined(); + }); +});