From 4493bf3149c9b659335382f5eab53c04e54042de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Wed, 1 Jul 2026 08:12:42 -0500 Subject: [PATCH 1/5] feat: add file share link command --- package.json | 19 +++- scripts/release-contract.js | 3 +- src/commands/index.ts | 111 +++++++++++++++++++++++ src/test/suite/commands.test.ts | 92 +++++++++++++++++++ src/test/suite/extension.test.ts | 16 ++++ src/test/suite/lmToolsFailure.test.ts | 9 +- src/test/unit/propertyInvariants.test.ts | 3 +- src/tools/definitions/presignUrl.ts | 2 +- src/tools/presignUrlLimits.ts | 2 +- 9 files changed, 249 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 8990010f..01ca76da 100644 --- a/package.json +++ b/package.json @@ -137,6 +137,12 @@ "category": "B2", "icon": "$(key)" }, + { + "command": "b2.copyShareLink", + "title": "Copy Share Link", + "category": "B2", + "icon": "$(link)" + }, { "command": "b2.openFile", "title": "Open File", @@ -195,6 +201,10 @@ "command": "b2.copyFileId", "when": "false" }, + { + "command": "b2.copyShareLink", + "when": "false" + }, { "command": "b2.openFile", "when": "false" @@ -320,6 +330,11 @@ "command": "b2.copyFileId", "when": "view == b2Buckets && viewItem == file", "group": "1_copy@2" + }, + { + "command": "b2.copyShareLink", + "when": "view == b2Buckets && viewItem == file", + "group": "1_copy@3" } ] }, @@ -546,9 +561,9 @@ "expiresIn": { "type": "integer", "minimum": 1, - "maximum": 3600, + "maximum": 604800, "default": 300, - "description": "URL validity duration in seconds. Default: 300 (5 minutes). Maximum: 3600 (1 hour)." + "description": "URL validity duration in seconds. Default: 300 (5 minutes). Maximum: 604800 (7 days)." } }, "required": [ diff --git a/scripts/release-contract.js b/scripts/release-contract.js index 9c0ecc1b..24f055c9 100644 --- a/scripts/release-contract.js +++ b/scripts/release-contract.js @@ -41,6 +41,7 @@ const manifestContract = { "b2.loadMore", "b2.copyPath", "b2.copyFileId", + "b2.copyShareLink", "b2.openFile", "b2.createBucket", "b2.changeBucketVisibility", @@ -59,7 +60,7 @@ const manifestContract = { "b2_deleteFile", "b2_presignUrl", ], - contributesSha256: "40a5f09a30986dfcb974032113ec6afaa2a694fa9fd12421b222955dac64eda7", + contributesSha256: "7bce248c46603d2f26fde83914d6f5a146ce56bb4eea5ea8a6c617848609cc22", }; function stableStringify(value) { diff --git a/src/commands/index.ts b/src/commands/index.ts index ee9b515d..acc9c281 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -36,6 +36,11 @@ import { isPostRequestB2MutationStateAmbiguous, } from "../errors"; import { log, logError } from "../logger"; +import { buildB2DownloadUrl } from "../utils/urlEncoding"; +import { + DEFAULT_PRESIGN_URL_EXPIRES_IN_SECONDS, + MAX_PRESIGN_URL_EXPIRES_IN_SECONDS, +} from "../tools/presignUrlLimits"; import { bucketTypeLabel, buildPublicBucketUnknownStateWarningMessage, @@ -265,11 +270,35 @@ export interface OpenFileCommandServices { getClient: () => B2Client | null; } +export interface CopyShareLinkCommandServices { + getClient: () => Pick | null; + writeClipboardText?: (value: string) => Thenable; + now?: () => Date; +} + export interface CreateFolderCommandServices { treeProvider: Pick; getClient: () => B2Client | null; } +function parseShareLinkExpiresIn(input: string): number | undefined { + const trimmed = input.trim(); + if (!/^\d+$/.test(trimmed)) { + return undefined; + } + + const expiresIn = Number(trimmed); + return Number.isSafeInteger(expiresIn) ? expiresIn : undefined; +} + +export function validateShareLinkExpiresInInput(value: string): string | undefined { + const expiresIn = parseShareLinkExpiresIn(value); + if (expiresIn === undefined || expiresIn < 1 || expiresIn > MAX_PRESIGN_URL_EXPIRES_IN_SECONDS) { + return `Enter a whole number of seconds from 1 to ${MAX_PRESIGN_URL_EXPIRES_IN_SECONDS}.`; + } + return undefined; +} + export async function openFileCommand( item: FileTreeItem, services: OpenFileCommandServices, @@ -321,6 +350,81 @@ export async function openFileCommand( } } +export async function copyShareLinkCommand( + item: FileTreeItem | undefined, + services: CopyShareLinkCommandServices, +): Promise { + const client = services.getClient(); + if (!client) { + vscode.window.showErrorMessage("B2: Not authenticated."); + return; + } + if (!item) { + vscode.window.showErrorMessage("B2: Select a file first."); + return; + } + + const expiresInInput = await vscode.window.showInputBox({ + title: "Copy Share Link", + prompt: `Enter link TTL in seconds (1-${MAX_PRESIGN_URL_EXPIRES_IN_SECONDS})`, + value: String(DEFAULT_PRESIGN_URL_EXPIRES_IN_SECONDS), + placeHolder: "3600", + ignoreFocusOut: true, + validateInput: validateShareLinkExpiresInInput, + }); + if (!expiresInInput) { + return; + } + + const validationError = validateShareLinkExpiresInInput(expiresInInput); + if (validationError) { + vscode.window.showErrorMessage(`B2: ${validationError}`); + return; + } + + const expiresIn = parseShareLinkExpiresIn(expiresInInput); + if (expiresIn === undefined) { + vscode.window.showErrorMessage("B2: Invalid share link TTL."); + return; + } + + try { + const { expiresAt } = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Creating share link for "${item.file.fileName}"...`, + cancellable: false, + }, + async () => { + const { authorizationToken } = await item.bucket.getDownloadAuthorization( + item.file.fileName, + expiresIn, + ); + const url = buildB2DownloadUrl( + client.accountInfo.getDownloadUrl(), + item.bucketName, + item.file.fileName, + authorizationToken, + ); + const writeClipboardText = + services.writeClipboardText ?? ((value: string) => vscode.env.clipboard.writeText(value)); + await writeClipboardText(url); + const now = services.now?.() ?? new Date(); + return { + expiresAt: new Date(now.getTime() + expiresIn * 1000).toISOString(), + }; + }, + ); + + log( + `Created prefix-scoped share link for b2://${item.bucketName}/${item.file.fileName} expiring at ${expiresAt}.`, + ); + vscode.window.showInformationMessage(`B2: Share link copied. Expires at ${expiresAt}.`); + } catch (error) { + showCommandError("B2: Failed to create share link", error); + } +} + export async function authenticateCommand(services: CommandServices): Promise { const { authService, context, treeProvider, setClient } = services; const createClient = services.createClient ?? createConfiguredB2Client; @@ -877,6 +981,13 @@ export function registerCommands(services: CommandServices): void { }), ); + // ── Copy Share Link ───────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand("b2.copyShareLink", (item?: FileTreeItem) => + copyShareLinkCommand(item, { getClient }), + ), + ); + // ── Open File ─────────────────────────────────────────────────────────── context.subscriptions.push( vscode.commands.registerCommand("b2.openFile", (item: FileTreeItem) => diff --git a/src/test/suite/commands.test.ts b/src/test/suite/commands.test.ts index 1859785b..c643dbc1 100644 --- a/src/test/suite/commands.test.ts +++ b/src/test/suite/commands.test.ts @@ -21,6 +21,7 @@ import { authenticateCommand, buildCommandErrorMessage, changeBucketVisibilityCommand, + copyShareLinkCommand, createBucketCommand, createFolderCommand, openFileCommand, @@ -262,6 +263,97 @@ suite("B2 commands error handling", () => { } }); + test("copy share link uses selected TTL and exact file prefix", async () => { + const authorizationCalls: Array<{ fileNamePrefix: string; validDurationInSeconds: number }> = + []; + const item = { + bucketName: "share-bucket", + file: { + fileName: "reports/Q4 plan.pdf", + fileId: "file-id", + contentLength: 42, + }, + bucket: { + async getDownloadAuthorization( + fileNamePrefix: string, + validDurationInSeconds: number, + ): Promise<{ authorizationToken: string }> { + authorizationCalls.push({ fileNamePrefix, validDurationInSeconds }); + return { authorizationToken: "token value+" }; + }, + }, + } as unknown as FileTreeItem; + const client = { + accountInfo: { + getDownloadUrl: () => "https://download.example.com/", + }, + } as unknown as Pick; + const writes: string[] = []; + + const ui = await withWindowUiStubs({ inputValues: ["604800"] }, () => + copyShareLinkCommand(item, { + getClient: () => client, + writeClipboardText: (value) => { + writes.push(value); + return Promise.resolve(); + }, + now: () => new Date("2026-07-01T00:00:00.000Z"), + }), + ); + + assert.deepStrictEqual(authorizationCalls, [ + { fileNamePrefix: "reports/Q4 plan.pdf", validDurationInSeconds: 604800 }, + ]); + assert.deepStrictEqual(writes, [ + "https://download.example.com/file/share-bucket/reports/Q4%20plan.pdf?Authorization=token%20value%2B", + ]); + assert.strictEqual(ui.progress.length, 1); + assert.strictEqual(ui.progress[0]?.cancellable, false); + assert.deepStrictEqual(ui.errors, []); + assert.strictEqual(ui.infos.length, 1); + assert.match(ui.infos[0] ?? "", /Expires at 2026-07-08T00:00:00\.000Z/); + }); + + test("copy share link rejects TTL above B2 maximum before authorization", async () => { + let authorizationCalled = false; + const item = { + bucketName: "share-bucket", + file: { + fileName: "report.txt", + fileId: "file-id", + contentLength: 42, + }, + bucket: { + async getDownloadAuthorization(): Promise<{ authorizationToken: string }> { + authorizationCalled = true; + return { authorizationToken: "token" }; + }, + }, + } as unknown as FileTreeItem; + const client = { + accountInfo: { + getDownloadUrl: () => "https://download.example.com", + }, + } as unknown as Pick; + const writes: string[] = []; + + const ui = await withWindowUiStubs({ inputValues: ["604801"] }, () => + copyShareLinkCommand(item, { + getClient: () => client, + writeClipboardText: (value) => { + writes.push(value); + return Promise.resolve(); + }, + }), + ); + + assert.strictEqual(authorizationCalled, false); + assert.deepStrictEqual(writes, []); + assert.deepStrictEqual(ui.progress, []); + assert.strictEqual(ui.errors.length, 1); + assert.match(ui.errors[0] ?? "", /1 to 604800/); + }); + test("create folder times out stalled folder marker uploads", async () => { let refreshed = false; let uploadSignal: AbortSignal | undefined; diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index d651cd32..3ceea5d8 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -53,6 +53,7 @@ suite("B2 Extension Test Suite", () => { "b2.loadMore", "b2.copyPath", "b2.copyFileId", + "b2.copyShareLink", "b2.openFile", "b2.createBucket", "b2.changeBucketVisibility", @@ -83,6 +84,21 @@ suite("B2 Extension Test Suite", () => { } }); + test("Copy share link menu is scoped to files", async () => { + const extension = vscode.extensions.getExtension("backblaze.b2-vscode"); + assert.ok(extension, "Backblaze B2 extension should be discoverable by ID"); + + const viewItemMenus = extension.packageJSON.contributes.menus[ + "view/item/context" + ] as MenuContribution[]; + const copyShareLinkMenus = viewItemMenus.filter( + (entry) => entry.command === "b2.copyShareLink", + ); + + assert.strictEqual(copyShareLinkMenus.length, 1); + assert.strictEqual(copyShareLinkMenus[0]?.when, "view == b2Buckets && viewItem == file"); + }); + test("listFiles package contribution declares an integer limit schema", () => { const extension = vscode.extensions.getExtension("backblaze.b2-vscode"); assert.ok(extension, "Backblaze B2 extension should be discoverable by ID"); diff --git a/src/test/suite/lmToolsFailure.test.ts b/src/test/suite/lmToolsFailure.test.ts index b5fef54e..9c28f29e 100644 --- a/src/test/suite/lmToolsFailure.test.ts +++ b/src/test/suite/lmToolsFailure.test.ts @@ -27,6 +27,7 @@ import { getFileInfoOperation } from "../../tools/operations/getFileInfo"; import { listBucketsOperation } from "../../tools/operations/listBuckets"; import { listFilesOperation } from "../../tools/operations/listFiles"; import { presignUrlOperation } from "../../tools/operations/presignUrl"; +import { MAX_PRESIGN_URL_EXPIRES_IN_SECONDS } from "../../tools/presignUrlLimits"; import { uploadFileOperation } from "../../tools/operations/uploadFile"; import { registerB2Tools } from "../../tools/registration"; import { withWindowUiStubs } from "./windowStubs"; @@ -557,10 +558,14 @@ suite("B2 LM tool failure handling", () => { await assert.rejects( () => presignUrlOperation.execute( - { bucket: "b", path: "a.txt", expiresIn: 3_601 }, + { + bucket: "b", + path: "a.txt", + expiresIn: MAX_PRESIGN_URL_EXPIRES_IN_SECONDS + 1, + }, { getClient: () => client }, ), - /between 1 and 3600 seconds/i, + new RegExp(`between 1 and ${MAX_PRESIGN_URL_EXPIRES_IN_SECONDS} seconds`, "i"), ); }); diff --git a/src/test/unit/propertyInvariants.test.ts b/src/test/unit/propertyInvariants.test.ts index 9f328d32..3cb45d08 100644 --- a/src/test/unit/propertyInvariants.test.ts +++ b/src/test/unit/propertyInvariants.test.ts @@ -22,6 +22,7 @@ import { createPrivateTempRoot, releasePrivateTempRoot } from "../../utils/priva import { buildB2DownloadUrl, encodeB2FileNameForUrl } from "../../utils/urlEncoding"; import { humanSize } from "../../utils/humanSize"; import { presignUrlOperation } from "../../tools/operations/presignUrl"; +import { MAX_PRESIGN_URL_EXPIRES_IN_SECONDS } from "../../tools/presignUrlLimits"; import type { ToolExtras } from "../../tools/types"; const PROPERTY_RUNS = 1000; @@ -594,7 +595,7 @@ test("presign operation rejects empty and folder-prefix paths before B2 calls", }); test("presign operation rejects invalid expiresIn before B2 calls", async () => { - for (const expiresIn of [-1, 0, 1.5, 3601, Number.NaN]) { + for (const expiresIn of [-1, 0, 1.5, MAX_PRESIGN_URL_EXPIRES_IN_SECONDS + 1, Number.NaN]) { let bucketLookupWasCalled = false; const extras = { getClient: () => ({ diff --git a/src/tools/definitions/presignUrl.ts b/src/tools/definitions/presignUrl.ts index a9c57129..e7385b6f 100644 --- a/src/tools/definitions/presignUrl.ts +++ b/src/tools/definitions/presignUrl.ts @@ -48,7 +48,7 @@ export const presignUrlTool: B2ToolDefinition = { minimum: 1, maximum: MAX_PRESIGN_URL_EXPIRES_IN_SECONDS, default: DEFAULT_PRESIGN_URL_EXPIRES_IN_SECONDS, - description: `URL validity duration in seconds. Default: ${DEFAULT_PRESIGN_URL_EXPIRES_IN_SECONDS} (5 minutes). Maximum: ${MAX_PRESIGN_URL_EXPIRES_IN_SECONDS} (1 hour).`, + description: `URL validity duration in seconds. Default: ${DEFAULT_PRESIGN_URL_EXPIRES_IN_SECONDS} (5 minutes). Maximum: ${MAX_PRESIGN_URL_EXPIRES_IN_SECONDS} (7 days).`, }, }, required: ["bucket", "path"], diff --git a/src/tools/presignUrlLimits.ts b/src/tools/presignUrlLimits.ts index 6834f742..34c443af 100644 --- a/src/tools/presignUrlLimits.ts +++ b/src/tools/presignUrlLimits.ts @@ -4,5 +4,5 @@ * @module tools/presignUrlLimits */ -export const MAX_PRESIGN_URL_EXPIRES_IN_SECONDS = 60 * 60; +export const MAX_PRESIGN_URL_EXPIRES_IN_SECONDS = 7 * 24 * 60 * 60; export const DEFAULT_PRESIGN_URL_EXPIRES_IN_SECONDS = 5 * 60; From 247d138a5b08cf508d04b1a14185adc8aca4506a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Wed, 1 Jul 2026 08:27:19 -0500 Subject: [PATCH 2/5] fix: harden share link authorization --- src/commands/index.ts | 118 ++++++++++++++++---- src/errors.ts | 11 ++ src/services/shareLink.ts | 169 +++++++++++++++++++++++++++++ src/test/suite/commands.test.ts | 136 ++++++++++++++++++++++- src/tools/operations/presignUrl.ts | 142 +++++++----------------- 5 files changed, 451 insertions(+), 125 deletions(-) create mode 100644 src/services/shareLink.ts diff --git a/src/commands/index.ts b/src/commands/index.ts index acc9c281..8f3dc1a4 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -31,12 +31,21 @@ import { import { B2MutationTimeoutError, B2PartialFailureError, + B2ShareLinkError, + formatB2DiagnosticMessage, formatB2UserMessage, isBucketRevisionConflict, isPostRequestB2MutationStateAmbiguous, + redactSensitiveText, } from "../errors"; import { log, logError } from "../logger"; -import { buildB2DownloadUrl } from "../utils/urlEncoding"; +import { + createPrefixScopedDownloadUrl, + SHARE_LINK_AUTHORIZATION_TIMEOUT_MS, + throwIfAborted, + type LateShareLinkAuthorizationEvent, +} from "../services/shareLink"; +import { withTimeout } from "../services/transferTimeout"; import { DEFAULT_PRESIGN_URL_EXPIRES_IN_SECONDS, MAX_PRESIGN_URL_EXPIRES_IN_SECONDS, @@ -273,6 +282,8 @@ export interface OpenFileCommandServices { export interface CopyShareLinkCommandServices { getClient: () => Pick | null; writeClipboardText?: (value: string) => Thenable; + shareLinkTimeoutMs?: number; + onLateAuthorization?: (event: LateShareLinkAuthorizationEvent) => void; now?: () => Date; } @@ -299,6 +310,53 @@ export function validateShareLinkExpiresInInput(value: string): string | undefin return undefined; } +function signalFromCancellationToken(token: vscode.CancellationToken): { + readonly signal: AbortSignal; + dispose(): void; +} { + const controller = new AbortController(); + const abort = () => { + if (!controller.signal.aborted) { + controller.abort(new vscode.CancellationError()); + } + }; + if (token.isCancellationRequested) { + abort(); + } + const subscription = token.onCancellationRequested(abort); + return { + signal: controller.signal, + dispose: () => subscription.dispose(), + }; +} + +function redactedCommandLateAuthorizationError(error: unknown): unknown { + if (error instanceof Error) { + const redactedError = new Error(redactSensitiveText(error.message)); + redactedError.name = error.name; + return redactedError; + } + return error === undefined ? undefined : redactSensitiveText(String(error)); +} + +function logShareLinkLateAuthorization(event: LateShareLinkAuthorizationEvent): void { + const message = + event.status === "completed" + ? `Share-link download authorization completed after timeout or cancellation for prefix ${event.filePath}; the discarded B2 token may remain valid until expiry.` + : `Share-link download authorization failed after timeout or cancellation for prefix ${event.filePath}`; + const detail = redactedCommandLateAuthorizationError( + event.status === "completed" ? event.reason : event.error, + ); + const safeMessage = redactSensitiveText(message); + + if (detail === undefined) { + log(safeMessage); + return; + } + + log(`${safeMessage} - ${formatB2DiagnosticMessage(detail)}`); +} + export async function openFileCommand( item: FileTreeItem, services: OpenFileCommandServices, @@ -393,34 +451,56 @@ export async function copyShareLinkCommand( { location: vscode.ProgressLocation.Notification, title: `Creating share link for "${item.file.fileName}"...`, - cancellable: false, + cancellable: true, }, - async () => { - const { authorizationToken } = await item.bucket.getDownloadAuthorization( - item.file.fileName, - expiresIn, - ); - const url = buildB2DownloadUrl( - client.accountInfo.getDownloadUrl(), - item.bucketName, - item.file.fileName, - authorizationToken, - ); + async (_progress, token) => { const writeClipboardText = services.writeClipboardText ?? ((value: string) => vscode.env.clipboard.writeText(value)); - await writeClipboardText(url); - const now = services.now?.() ?? new Date(); - return { - expiresAt: new Date(now.getTime() + expiresIn * 1000).toISOString(), - }; + const cancellation = signalFromCancellationToken(token); + try { + return await withTimeout( + async (signal) => { + const shareLink = await createPrefixScopedDownloadUrl({ + bucket: item.bucket, + bucketName: item.bucketName, + filePath: item.file.fileName, + downloadUrl: client.accountInfo.getDownloadUrl(), + expiresIn, + signal, + onLateAuthorization: services.onLateAuthorization ?? logShareLinkLateAuthorization, + }); + throwIfAborted(signal); + await writeClipboardText(shareLink.url); + throwIfAborted(signal); + const now = services.now?.() ?? new Date(); + return { + expiresAt: new Date(now.getTime() + expiresIn * 1000).toISOString(), + }; + }, + services.shareLinkTimeoutMs ?? SHARE_LINK_AUTHORIZATION_TIMEOUT_MS, + `Share link for b2://${item.bucketName}/${item.file.fileName}`, + { + signal: cancellation.signal, + createTimeoutError: (description, timeoutMs) => + new B2ShareLinkError(`${description} timed out after ${timeoutMs} ms.`), + }, + ); + } finally { + cancellation.dispose(); + } }, ); log( `Created prefix-scoped share link for b2://${item.bucketName}/${item.file.fileName} expiring at ${expiresAt}.`, ); - vscode.window.showInformationMessage(`B2: Share link copied. Expires at ${expiresAt}.`); + vscode.window.showInformationMessage( + `B2: Share link copied. Expires at ${expiresAt}. Future same-prefix objects may also be downloadable until then.`, + ); } catch (error) { + if (error instanceof vscode.CancellationError) { + return; + } showCommandError("B2: Failed to create share link", error); } } diff --git a/src/errors.ts b/src/errors.ts index 979c2bd6..5d2f4c9f 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -113,6 +113,13 @@ export class B2ToolInputError extends Error { } } +export class B2ShareLinkError extends Error { + constructor(message: string) { + super(message); + this.name = "B2ShareLinkError"; + } +} + /** Error used when extension-side lookup confirms a B2 resource is absent. */ export class B2ResourceNotFoundError extends Error { constructor(message: string) { @@ -452,6 +459,10 @@ export function formatB2UserMessage(error: unknown): string { return redactSensitiveText(getErrorMessage(error)); } + if (error instanceof B2ShareLinkError || matchesErrorName(error, "B2ShareLinkError")) { + return redactSensitiveText(getErrorMessage(error)); + } + if (isMutationTimeout(error)) { return "The B2 request timed out before the extension could confirm the final state. Refresh the bucket tree and verify the bucket in Backblaze before retrying."; } diff --git a/src/services/shareLink.ts b/src/services/shareLink.ts new file mode 100644 index 00000000..b32df396 --- /dev/null +++ b/src/services/shareLink.ts @@ -0,0 +1,169 @@ +/** + * Shared B2 share-link creation helpers. + * + * @module services/shareLink + */ + +import { isMissingCapabilityError } from "../utils/b2Errors"; +import { buildB2DownloadUrl } from "../utils/urlEncoding"; +import { B2ShareLinkError } from "../errors"; + +export const SHARE_LINK_AUTHORIZATION_TIMEOUT_MS = 30_000; + +export interface ShareLinkFileEntry { + readonly fileName: string; + readonly action?: string; +} + +export interface ShareLinkBucket { + listFileNames(options: { prefix: string; pageSize: number }): Promise<{ + files: readonly ShareLinkFileEntry[]; + nextFileName?: string | null; + }>; + getDownloadAuthorization( + fileNamePrefix: string, + validDurationInSeconds: number, + ): Promise<{ authorizationToken: string }>; +} + +export interface LateShareLinkAuthorizationEvent { + readonly status: "completed" | "failed"; + readonly filePath: string; + readonly reason?: unknown; + readonly error?: unknown; +} + +export interface CreatePrefixScopedDownloadUrlOptions { + readonly bucket: ShareLinkBucket; + readonly bucketName: string; + readonly filePath: string; + readonly downloadUrl: string; + readonly expiresIn: number; + readonly signal?: AbortSignal; + readonly missingListCapabilityMessage?: string; + readonly onAuthorizationStarted?: () => void; + readonly onAuthorizationSettled?: () => void; + readonly onLateAuthorization?: (event: LateShareLinkAuthorizationEvent) => void; +} + +export interface PrefixScopedDownloadUrlResult { + readonly url: string; + readonly expiresIn: number; + readonly authorizedPrefix: string; +} + +export function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw signal.reason ?? new DOMException("Aborted", "AbortError"); + } +} + +function isCurrentDownloadableFile(file: ShareLinkFileEntry): boolean { + return file.action !== "folder" && file.action !== "hide"; +} + +export async function assertExactCurrentObjectWithoutAdjacentPrefix( + bucket: ShareLinkBucket, + filePath: string, + signal: AbortSignal | undefined, + options: { readonly missingListCapabilityMessage?: string } = {}, +): Promise { + let page: Awaited>; + try { + throwIfAborted(signal); + // The current B2 SDK list/auth helpers do not accept AbortSignal. The + // surrounding timeout still bounds caller latency and this explicit check + // prevents issuing later calls after a timeout or cancellation. Calls are + // sequential, so at most one SDK request per invocation can continue in the + // background after the bounded caller result returns. + page = await bucket.listFileNames({ prefix: filePath, pageSize: 2 }); + throwIfAborted(signal); + } catch (error) { + if (isMissingCapabilityError(error)) { + throw new B2ShareLinkError( + options.missingListCapabilityMessage ?? + "Creating a share link requires the listFiles capability to verify the object before issuing B2's prefix-scoped download authorization.", + ); + } + throw error; + } + + const currentMatches = page.files.filter(isCurrentDownloadableFile); + const exactMatches = currentMatches.filter((file) => file.fileName === filePath); + if (exactMatches.length !== 1) { + throw new B2ShareLinkError( + "path must exactly match one downloadable B2 file before a presigned URL can be created.", + ); + } + + if (currentMatches.some((file) => file.fileName !== filePath) || page.nextFileName) { + throw new B2ShareLinkError( + "path matches additional current objects sharing this prefix; B2 would authorize ALL object names beginning with that value, not just this file.", + ); + } +} + +async function getDownloadAuthorizationWithLateLogging( + options: Pick< + CreatePrefixScopedDownloadUrlOptions, + "bucket" | "filePath" | "expiresIn" | "signal" | "onLateAuthorization" + >, +): Promise<{ authorizationToken: string }> { + const authorizationPromise = options.bucket.getDownloadAuthorization( + options.filePath, + options.expiresIn, + ); + void authorizationPromise.then( + () => { + if (options.signal?.aborted) { + options.onLateAuthorization?.({ + status: "completed", + filePath: options.filePath, + reason: options.signal.reason, + }); + } + }, + (error) => { + if (options.signal?.aborted) { + options.onLateAuthorization?.({ + status: "failed", + filePath: options.filePath, + error, + }); + } + }, + ); + + return authorizationPromise; +} + +export async function createPrefixScopedDownloadUrl( + options: CreatePrefixScopedDownloadUrlOptions, +): Promise { + await assertExactCurrentObjectWithoutAdjacentPrefix( + options.bucket, + options.filePath, + options.signal, + { + missingListCapabilityMessage: options.missingListCapabilityMessage, + }, + ); + throwIfAborted(options.signal); + + options.onAuthorizationStarted?.(); + const { authorizationToken } = await getDownloadAuthorizationWithLateLogging(options).finally( + () => options.onAuthorizationSettled?.(), + ); + throwIfAborted(options.signal); + + return { + url: buildB2DownloadUrl( + options.downloadUrl, + options.bucketName, + options.filePath, + authorizationToken, + ), + expiresIn: options.expiresIn, + authorizedPrefix: options.filePath, + }; +} diff --git a/src/test/suite/commands.test.ts b/src/test/suite/commands.test.ts index c643dbc1..ea307332 100644 --- a/src/test/suite/commands.test.ts +++ b/src/test/suite/commands.test.ts @@ -264,6 +264,7 @@ suite("B2 commands error handling", () => { }); test("copy share link uses selected TTL and exact file prefix", async () => { + const listCalls: Array<{ prefix: string; pageSize: number }> = []; const authorizationCalls: Array<{ fileNamePrefix: string; validDurationInSeconds: number }> = []; const item = { @@ -274,6 +275,13 @@ suite("B2 commands error handling", () => { contentLength: 42, }, bucket: { + async listFileNames(options: { prefix: string; pageSize: number }) { + listCalls.push(options); + return { + files: [{ fileName: "reports/Q4 plan.pdf" }], + nextFileName: null, + }; + }, async getDownloadAuthorization( fileNamePrefix: string, validDurationInSeconds: number, @@ -301,6 +309,7 @@ suite("B2 commands error handling", () => { }), ); + assert.deepStrictEqual(listCalls, [{ prefix: "reports/Q4 plan.pdf", pageSize: 2 }]); assert.deepStrictEqual(authorizationCalls, [ { fileNamePrefix: "reports/Q4 plan.pdf", validDurationInSeconds: 604800 }, ]); @@ -308,10 +317,63 @@ suite("B2 commands error handling", () => { "https://download.example.com/file/share-bucket/reports/Q4%20plan.pdf?Authorization=token%20value%2B", ]); assert.strictEqual(ui.progress.length, 1); - assert.strictEqual(ui.progress[0]?.cancellable, false); + assert.strictEqual(ui.progress[0]?.cancellable, true); assert.deepStrictEqual(ui.errors, []); assert.strictEqual(ui.infos.length, 1); assert.match(ui.infos[0] ?? "", /Expires at 2026-07-08T00:00:00\.000Z/); + assert.match(ui.infos[0] ?? "", /Future same-prefix objects/i); + }); + + test("copy share link rejects adjacent same-prefix objects before authorization", async () => { + for (const [selectedPath, adjacentPath] of [ + ["report/report.pdf", "report/report.pdf.secret"], + ["report.pdf", "report.pdf/report.pdf.secret"], + ] as const) { + let authorizationCalled = false; + const item = { + bucketName: "share-bucket", + file: { + fileName: selectedPath, + fileId: "file-id", + contentLength: 42, + }, + bucket: { + async listFileNames(options: { prefix: string; pageSize: number }) { + assert.deepStrictEqual(options, { prefix: selectedPath, pageSize: 2 }); + return { + files: [{ fileName: selectedPath }, { fileName: adjacentPath }], + nextFileName: null, + }; + }, + async getDownloadAuthorization(): Promise<{ authorizationToken: string }> { + authorizationCalled = true; + return { authorizationToken: "token" }; + }, + }, + } as unknown as FileTreeItem; + const client = { + accountInfo: { + getDownloadUrl: () => "https://download.example.com", + }, + } as unknown as Pick; + const writes: string[] = []; + + const ui = await withWindowUiStubs({ inputValues: ["300"] }, () => + copyShareLinkCommand(item, { + getClient: () => client, + writeClipboardText: (value) => { + writes.push(value); + return Promise.resolve(); + }, + }), + ); + + assert.strictEqual(authorizationCalled, false); + assert.deepStrictEqual(writes, []); + assert.deepStrictEqual(ui.infos, []); + assert.strictEqual(ui.errors.length, 1); + assert.match(ui.errors[0] ?? "", /additional current objects sharing this prefix/i); + } }); test("copy share link rejects TTL above B2 maximum before authorization", async () => { @@ -354,6 +416,78 @@ suite("B2 commands error handling", () => { assert.match(ui.errors[0] ?? "", /1 to 604800/); }); + test("copy share link times out stalled authorization and logs late completion", async () => { + let resolveAuthorization: + | ((value: { readonly authorizationToken: string }) => void) + | undefined; + let authorizationCalled = false; + const lateEvents: Array<{ status: string; filePath: string; reason?: unknown }> = []; + const item = { + bucketName: "share-bucket", + file: { + fileName: "report.pdf", + fileId: "file-id", + contentLength: 42, + }, + bucket: { + async listFileNames() { + return { + files: [{ fileName: "report.pdf" }], + nextFileName: null, + }; + }, + async getDownloadAuthorization(): Promise<{ authorizationToken: string }> { + authorizationCalled = true; + return new Promise((resolve) => { + resolveAuthorization = resolve; + }); + }, + }, + } as unknown as FileTreeItem; + const client = { + accountInfo: { + getDownloadUrl: () => "https://download.example.com", + }, + } as unknown as Pick; + const writes: string[] = []; + + const ui = await withWindowUiStubs({ inputValues: ["300"] }, () => + copyShareLinkCommand(item, { + getClient: () => client, + shareLinkTimeoutMs: 5, + writeClipboardText: (value) => { + writes.push(value); + return Promise.resolve(); + }, + onLateAuthorization: (event) => { + lateEvents.push({ + status: event.status, + filePath: event.filePath, + reason: event.reason, + }); + }, + }), + ); + + assert.strictEqual(authorizationCalled, true); + assert.deepStrictEqual(writes, []); + assert.deepStrictEqual(ui.infos, []); + assert.strictEqual(ui.progress.length, 1); + assert.strictEqual(ui.progress[0]?.cancellable, true); + assert.strictEqual(ui.errors.length, 1); + assert.match(ui.errors[0] ?? "", /timed out/i); + + assert.ok(resolveAuthorization); + resolveAuthorization({ authorizationToken: "late-token-that-must-not-be-logged" }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.strictEqual(lateEvents.length, 1); + assert.strictEqual(lateEvents[0]?.status, "completed"); + assert.strictEqual(lateEvents[0]?.filePath, "report.pdf"); + assert.ok(lateEvents[0]?.reason instanceof Error); + assert.doesNotMatch(String(lateEvents[0]?.reason), /late-token/); + }); + test("create folder times out stalled folder marker uploads", async () => { let refreshed = false; let uploadSignal: AbortSignal | undefined; diff --git a/src/tools/operations/presignUrl.ts b/src/tools/operations/presignUrl.ts index a6b1b4d9..df9cd4a7 100644 --- a/src/tools/operations/presignUrl.ts +++ b/src/tools/operations/presignUrl.ts @@ -12,9 +12,13 @@ import { formatB2DiagnosticMessage, redactSensitiveText, } from "../../errors"; +import { + createPrefixScopedDownloadUrl, + SHARE_LINK_AUTHORIZATION_TIMEOUT_MS, + throwIfAborted, + type LateShareLinkAuthorizationEvent, +} from "../../services/shareLink"; import { withTimeout } from "../../services/transferTimeout"; -import { isMissingCapabilityError } from "../../utils/b2Errors"; -import { buildB2DownloadUrl } from "../../utils/urlEncoding"; import { DEFAULT_PRESIGN_URL_EXPIRES_IN_SECONDS, MAX_PRESIGN_URL_EXPIRES_IN_SECONDS, @@ -36,8 +40,6 @@ interface PresignUrlResult { type PresignUrlLateAuthorizationLogger = (message: string, error?: unknown) => void; -const PRESIGN_URL_OPERATION_TIMEOUT_MS = 30_000; - let presignUrlLateAuthorizationLogger: PresignUrlLateAuthorizationLogger = (message, error) => { const safeMessage = redactSensitiveText(message); const detail = error === undefined ? "" : ` - ${formatB2DiagnosticMessage(error)}`; @@ -79,6 +81,21 @@ function logPresignUrlLateAuthorization(message: string, error?: unknown): void ); } +function logPresignUrlLateAuthorizationEvent(event: LateShareLinkAuthorizationEvent): void { + if (event.status === "completed") { + logPresignUrlLateAuthorization( + `presignUrl download authorization completed after timeout or cancellation for prefix ${event.filePath}; the discarded B2 token may remain valid until expiry.`, + event.reason, + ); + return; + } + + logPresignUrlLateAuthorization( + `presignUrl download authorization failed after timeout or cancellation for prefix ${event.filePath}`, + event.error, + ); +} + export function normalizePresignUrlExpiration(expiresIn: number | undefined): number { if (expiresIn === undefined) { return DEFAULT_PRESIGN_URL_EXPIRES_IN_SECONDS; @@ -130,95 +147,6 @@ function signalFromCancellationToken(token: CancellationToken | undefined): { }; } -function throwIfAborted(signal: AbortSignal): void { - if (signal.aborted) { - throw signal.reason ?? new DOMException("Aborted", "AbortError"); - } -} - -interface PresignableBucket { - listFileNames(options: { prefix: string; pageSize: number }): Promise<{ - files: readonly { fileName: string; action?: string }[]; - nextFileName?: string | null; - }>; - getDownloadAuthorization( - filePath: string, - expiresIn: number, - ): Promise<{ authorizationToken: string }>; -} - -function isCurrentDownloadableFile(file: { action?: string }): boolean { - return file.action !== "folder" && file.action !== "hide"; -} - -async function assertExactCurrentObjectWithoutAdjacentPrefix( - bucket: PresignableBucket, - filePath: string, - signal: AbortSignal, -): Promise { - let page: Awaited>; - try { - throwIfAborted(signal); - // The current B2 SDK list/auth helpers do not accept AbortSignal. The - // surrounding withTimeout still bounds tool latency and this explicit check - // prevents issuing later calls after a timeout or LM cancellation. Calls - // are sequential, so at most one SDK request per presign invocation can - // continue in the background after the bounded tool result returns. - page = await bucket.listFileNames({ prefix: filePath, pageSize: 2 }); - throwIfAborted(signal); - } catch (error) { - if (isMissingCapabilityError(error)) { - throw new Error( - "presignUrl requires the listFiles capability to verify the object before issuing B2's prefix-scoped download authorization.", - ); - } - throw error; - } - - const currentMatches = page.files.filter(isCurrentDownloadableFile); - const exactMatches = currentMatches.filter((file) => file.fileName === filePath); - if (exactMatches.length !== 1) { - throw new Error( - "path must exactly match one downloadable B2 file before a presigned URL can be created.", - ); - } - - if (currentMatches.some((file) => file.fileName !== filePath) || page.nextFileName) { - throw new Error( - "path matches additional current objects sharing this prefix; B2 would authorize ALL object names beginning with that value, not just this file.", - ); - } -} - -async function getDownloadAuthorizationWithAbortLogging( - bucket: PresignableBucket, - filePath: string, - expiresIn: number, - signal: AbortSignal, -): Promise<{ authorizationToken: string }> { - const authorizationPromise = bucket.getDownloadAuthorization(filePath, expiresIn); - void authorizationPromise.then( - () => { - if (signal.aborted) { - logPresignUrlLateAuthorization( - `presignUrl download authorization completed after timeout or cancellation for prefix ${filePath}; the discarded B2 token may remain valid until expiry.`, - signal.reason, - ); - } - }, - (error) => { - if (signal.aborted) { - logPresignUrlLateAuthorization( - `presignUrl download authorization failed after timeout or cancellation for prefix ${filePath}`, - error, - ); - } - }, - ); - - return authorizationPromise; -} - export const presignUrlOperation: B2ToolOperation = { async execute( params: PresignUrlParams, @@ -260,25 +188,29 @@ export const presignUrlOperation: B2ToolOperation { - authorizationInFlight = false; + missingListCapabilityMessage: + "presignUrl requires the listFiles capability to verify the object before issuing B2's prefix-scoped download authorization.", + onAuthorizationStarted: () => { + authorizationInFlight = true; + }, + onAuthorizationSettled: () => { + authorizationInFlight = false; + }, + onLateAuthorization: logPresignUrlLateAuthorizationEvent, }); throwIfAborted(signal); - const downloadUrl = client.accountInfo.getDownloadUrl(); - const url = buildB2DownloadUrl(downloadUrl, params.bucket, filePath, authorizationToken); return { - url, - expiresIn, - authorizedPrefix: filePath, + url: presigned.url, + expiresIn: presigned.expiresIn, + authorizedPrefix: presigned.authorizedPrefix, message: `Pre-signed URL created for B2 file-name prefix ${filePath}; it authorizes ALL B2 object names beginning with ${filePath}, not just this file, for ${expiresIn}s. ` + "Current bucket contents were checked and no adjacent same-prefix object was found. " + @@ -286,7 +218,7 @@ export const presignUrlOperation: B2ToolOperation Date: Wed, 1 Jul 2026 08:30:13 -0500 Subject: [PATCH 3/5] fix: validate empty share link ttl --- src/commands/index.ts | 2 +- src/test/suite/commands.test.ts | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/commands/index.ts b/src/commands/index.ts index 8f3dc1a4..fea108ca 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -430,7 +430,7 @@ export async function copyShareLinkCommand( ignoreFocusOut: true, validateInput: validateShareLinkExpiresInInput, }); - if (!expiresInInput) { + if (expiresInInput === undefined) { return; } diff --git a/src/test/suite/commands.test.ts b/src/test/suite/commands.test.ts index ea307332..3050007a 100644 --- a/src/test/suite/commands.test.ts +++ b/src/test/suite/commands.test.ts @@ -416,6 +416,46 @@ suite("B2 commands error handling", () => { assert.match(ui.errors[0] ?? "", /1 to 604800/); }); + test("copy share link rejects an empty TTL before authorization", async () => { + let authorizationCalled = false; + const item = { + bucketName: "share-bucket", + file: { + fileName: "report.txt", + fileId: "file-id", + contentLength: 42, + }, + bucket: { + async getDownloadAuthorization(): Promise<{ authorizationToken: string }> { + authorizationCalled = true; + return { authorizationToken: "token" }; + }, + }, + } as unknown as FileTreeItem; + const client = { + accountInfo: { + getDownloadUrl: () => "https://download.example.com", + }, + } as unknown as Pick; + const writes: string[] = []; + + const ui = await withWindowUiStubs({ inputValues: [""] }, () => + copyShareLinkCommand(item, { + getClient: () => client, + writeClipboardText: (value) => { + writes.push(value); + return Promise.resolve(); + }, + }), + ); + + assert.strictEqual(authorizationCalled, false); + assert.deepStrictEqual(writes, []); + assert.deepStrictEqual(ui.progress, []); + assert.strictEqual(ui.errors.length, 1); + assert.match(ui.errors[0] ?? "", /1 to 604800/); + }); + test("copy share link times out stalled authorization and logs late completion", async () => { let resolveAuthorization: | ((value: { readonly authorizationToken: string }) => void) From 56ec32f50805ff8798d5c78544620691387807c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Wed, 1 Jul 2026 08:38:34 -0500 Subject: [PATCH 4/5] fix: polish share link errors --- src/commands/index.ts | 9 ++++++++- src/services/shareLink.ts | 2 +- src/test/suite/commands.test.ts | 2 ++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/commands/index.ts b/src/commands/index.ts index fea108ca..6145b457 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -357,6 +357,11 @@ function logShareLinkLateAuthorization(event: LateShareLinkAuthorizationEvent): log(`${safeMessage} - ${formatB2DiagnosticMessage(detail)}`); } +function formatShareLinkTimeoutForUser(timeoutMs: number): string { + const seconds = Math.max(1, Math.ceil(timeoutMs / 1000)); + return `${seconds} second${seconds === 1 ? "" : "s"}`; +} + export async function openFileCommand( item: FileTreeItem, services: OpenFileCommandServices, @@ -482,7 +487,9 @@ export async function copyShareLinkCommand( { signal: cancellation.signal, createTimeoutError: (description, timeoutMs) => - new B2ShareLinkError(`${description} timed out after ${timeoutMs} ms.`), + new B2ShareLinkError( + `${description} timed out after ${formatShareLinkTimeoutForUser(timeoutMs)}.`, + ), }, ); } finally { diff --git a/src/services/shareLink.ts b/src/services/shareLink.ts index b32df396..e8909ef1 100644 --- a/src/services/shareLink.ts +++ b/src/services/shareLink.ts @@ -92,7 +92,7 @@ export async function assertExactCurrentObjectWithoutAdjacentPrefix( const exactMatches = currentMatches.filter((file) => file.fileName === filePath); if (exactMatches.length !== 1) { throw new B2ShareLinkError( - "path must exactly match one downloadable B2 file before a presigned URL can be created.", + "path must exactly match one downloadable B2 file before a temporary download link can be created.", ); } diff --git a/src/test/suite/commands.test.ts b/src/test/suite/commands.test.ts index 3050007a..e6ad6f95 100644 --- a/src/test/suite/commands.test.ts +++ b/src/test/suite/commands.test.ts @@ -516,6 +516,8 @@ suite("B2 commands error handling", () => { assert.strictEqual(ui.progress[0]?.cancellable, true); assert.strictEqual(ui.errors.length, 1); assert.match(ui.errors[0] ?? "", /timed out/i); + assert.match(ui.errors[0] ?? "", /1 second/); + assert.doesNotMatch(ui.errors[0] ?? "", /\bms\b/); assert.ok(resolveAuthorization); resolveAuthorization({ authorizationToken: "late-token-that-must-not-be-logged" }); From 3baddd3cbf092ace169aafe0237132722670b4fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Wed, 1 Jul 2026 08:52:54 -0500 Subject: [PATCH 5/5] fix: move share link limits --- src/commands/index.ts | 2 +- .../presignUrlLimits.ts => services/shareLinkLimits.ts} | 4 ++-- src/test/suite/adversarialInputs.test.ts | 2 +- src/test/suite/extension.test.ts | 2 +- src/test/suite/lmToolOperations.test.ts | 2 +- src/test/suite/lmToolsFailure.test.ts | 2 +- src/test/unit/propertyInvariants.test.ts | 2 +- src/tools/definitions/presignUrl.ts | 2 +- src/tools/operations/presignUrl.ts | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) rename src/{tools/presignUrlLimits.ts => services/shareLinkLimits.ts} (61%) diff --git a/src/commands/index.ts b/src/commands/index.ts index 6145b457..6242e0d6 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -49,7 +49,7 @@ import { withTimeout } from "../services/transferTimeout"; import { DEFAULT_PRESIGN_URL_EXPIRES_IN_SECONDS, MAX_PRESIGN_URL_EXPIRES_IN_SECONDS, -} from "../tools/presignUrlLimits"; +} from "../services/shareLinkLimits"; import { bucketTypeLabel, buildPublicBucketUnknownStateWarningMessage, diff --git a/src/tools/presignUrlLimits.ts b/src/services/shareLinkLimits.ts similarity index 61% rename from src/tools/presignUrlLimits.ts rename to src/services/shareLinkLimits.ts index 34c443af..d26155d9 100644 --- a/src/tools/presignUrlLimits.ts +++ b/src/services/shareLinkLimits.ts @@ -1,7 +1,7 @@ /** - * Shared presigned URL validation limits. + * Shared B2 temporary download link expiry limits. * - * @module tools/presignUrlLimits + * @module services/shareLinkLimits */ export const MAX_PRESIGN_URL_EXPIRES_IN_SECONDS = 7 * 24 * 60 * 60; diff --git a/src/test/suite/adversarialInputs.test.ts b/src/test/suite/adversarialInputs.test.ts index 6a524e40..ef05d3b4 100644 --- a/src/test/suite/adversarialInputs.test.ts +++ b/src/test/suite/adversarialInputs.test.ts @@ -29,7 +29,7 @@ import { downloadFileOperation } from "../../tools/operations/downloadFile"; import { getFileInfoOperation } from "../../tools/operations/getFileInfo"; import { listFilesOperation } from "../../tools/operations/listFiles"; import { uploadFileOperation } from "../../tools/operations/uploadFile"; -import { MAX_PRESIGN_URL_EXPIRES_IN_SECONDS } from "../../tools/presignUrlLimits"; +import { MAX_PRESIGN_URL_EXPIRES_IN_SECONDS } from "../../services/shareLinkLimits"; import { resolveWorkspaceRelativePath, resolveToolLocalPath, diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index 3ceea5d8..e9e05816 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -11,7 +11,7 @@ import { createAuthenticatedClientSetter } from "../../extension"; import { downloadFileTool } from "../../tools/definitions/downloadFile"; import { presignUrlTool } from "../../tools/definitions/presignUrl"; import { uploadFileTool } from "../../tools/definitions/uploadFile"; -import { MAX_PRESIGN_URL_EXPIRES_IN_SECONDS } from "../../tools/presignUrlLimits"; +import { MAX_PRESIGN_URL_EXPIRES_IN_SECONDS } from "../../services/shareLinkLimits"; interface MenuContribution { command: string; diff --git a/src/test/suite/lmToolOperations.test.ts b/src/test/suite/lmToolOperations.test.ts index d16bf967..b86b9f1c 100644 --- a/src/test/suite/lmToolOperations.test.ts +++ b/src/test/suite/lmToolOperations.test.ts @@ -20,7 +20,7 @@ import { presignUrlOperation, setPresignUrlLateAuthorizationLoggerForTest, } from "../../tools/operations/presignUrl"; -import { MAX_PRESIGN_URL_EXPIRES_IN_SECONDS } from "../../tools/presignUrlLimits"; +import { MAX_PRESIGN_URL_EXPIRES_IN_SECONDS } from "../../services/shareLinkLimits"; import { uploadFileOperation } from "../../tools/operations/uploadFile"; import type { ToolExtras } from "../../tools/types"; import { diff --git a/src/test/suite/lmToolsFailure.test.ts b/src/test/suite/lmToolsFailure.test.ts index 9c28f29e..bbb4a21c 100644 --- a/src/test/suite/lmToolsFailure.test.ts +++ b/src/test/suite/lmToolsFailure.test.ts @@ -27,7 +27,7 @@ import { getFileInfoOperation } from "../../tools/operations/getFileInfo"; import { listBucketsOperation } from "../../tools/operations/listBuckets"; import { listFilesOperation } from "../../tools/operations/listFiles"; import { presignUrlOperation } from "../../tools/operations/presignUrl"; -import { MAX_PRESIGN_URL_EXPIRES_IN_SECONDS } from "../../tools/presignUrlLimits"; +import { MAX_PRESIGN_URL_EXPIRES_IN_SECONDS } from "../../services/shareLinkLimits"; import { uploadFileOperation } from "../../tools/operations/uploadFile"; import { registerB2Tools } from "../../tools/registration"; import { withWindowUiStubs } from "./windowStubs"; diff --git a/src/test/unit/propertyInvariants.test.ts b/src/test/unit/propertyInvariants.test.ts index 3cb45d08..6a5ffb2f 100644 --- a/src/test/unit/propertyInvariants.test.ts +++ b/src/test/unit/propertyInvariants.test.ts @@ -22,7 +22,7 @@ import { createPrivateTempRoot, releasePrivateTempRoot } from "../../utils/priva import { buildB2DownloadUrl, encodeB2FileNameForUrl } from "../../utils/urlEncoding"; import { humanSize } from "../../utils/humanSize"; import { presignUrlOperation } from "../../tools/operations/presignUrl"; -import { MAX_PRESIGN_URL_EXPIRES_IN_SECONDS } from "../../tools/presignUrlLimits"; +import { MAX_PRESIGN_URL_EXPIRES_IN_SECONDS } from "../../services/shareLinkLimits"; import type { ToolExtras } from "../../tools/types"; const PROPERTY_RUNS = 1000; diff --git a/src/tools/definitions/presignUrl.ts b/src/tools/definitions/presignUrl.ts index e7385b6f..7107efe9 100644 --- a/src/tools/definitions/presignUrl.ts +++ b/src/tools/definitions/presignUrl.ts @@ -9,7 +9,7 @@ import { inputText } from "./inputText"; import { DEFAULT_PRESIGN_URL_EXPIRES_IN_SECONDS, MAX_PRESIGN_URL_EXPIRES_IN_SECONDS, -} from "../presignUrlLimits"; +} from "../../services/shareLinkLimits"; function describeExpiresIn(value: unknown): string { if (value === undefined) { diff --git a/src/tools/operations/presignUrl.ts b/src/tools/operations/presignUrl.ts index df9cd4a7..38d512d9 100644 --- a/src/tools/operations/presignUrl.ts +++ b/src/tools/operations/presignUrl.ts @@ -22,7 +22,7 @@ import { withTimeout } from "../../services/transferTimeout"; import { DEFAULT_PRESIGN_URL_EXPIRES_IN_SECONDS, MAX_PRESIGN_URL_EXPIRES_IN_SECONDS, -} from "../presignUrlLimits"; +} from "../../services/shareLinkLimits"; import { normalizeB2ObjectNameInput } from "../b2ObjectName"; interface PresignUrlParams {