From b8c7ceaa4cdfc2010f9ee2dfd86f31c9c3931c96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Fri, 3 Jul 2026 08:47:44 -0500 Subject: [PATCH 1/8] feat: add shared B2 config provider --- package.json | 30 +- .../schemas/b2-config-bucketInfo.schema.json | 11 + resources/schemas/b2-config-cors.schema.json | 85 ++++ .../schemas/b2-config-lifecycle.schema.json | 43 ++ .../b2-config-notifications.schema.json | 93 +++++ src/extension.ts | 19 +- src/providers/b2ConfigFileSystemProvider.ts | 369 ++++++++++++++++++ src/providers/b2ConfigJson.ts | 250 ++++++++++++ src/test/unit/b2ConfigJson.test.ts | 130 ++++++ 9 files changed, 1026 insertions(+), 4 deletions(-) create mode 100644 resources/schemas/b2-config-bucketInfo.schema.json create mode 100644 resources/schemas/b2-config-cors.schema.json create mode 100644 resources/schemas/b2-config-lifecycle.schema.json create mode 100644 resources/schemas/b2-config-notifications.schema.json create mode 100644 src/providers/b2ConfigFileSystemProvider.ts create mode 100644 src/providers/b2ConfigJson.ts create mode 100644 src/test/unit/b2ConfigJson.test.ts diff --git a/package.json b/package.json index 8990010f..aad740ba 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "node": ">=22.13.0" }, "main": "./dist/extension.js", - "activationEvents": [], + "activationEvents": [ + "onFileSystem:b2-config" + ], "scripts": { "build:icons": "node scripts/build-icons.js", "clean:dist": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", @@ -335,6 +337,32 @@ } } }, + "jsonValidation": [ + { + "fileMatch": [ + "b2-config:/**/lifecycle.json" + ], + "url": "./resources/schemas/b2-config-lifecycle.schema.json" + }, + { + "fileMatch": [ + "b2-config:/**/cors.json" + ], + "url": "./resources/schemas/b2-config-cors.schema.json" + }, + { + "fileMatch": [ + "b2-config:/**/notifications.json" + ], + "url": "./resources/schemas/b2-config-notifications.schema.json" + }, + { + "fileMatch": [ + "b2-config:/**/bucketInfo.json" + ], + "url": "./resources/schemas/b2-config-bucketInfo.schema.json" + } + ], "languageModelTools": [ { "name": "b2_listBuckets", diff --git a/resources/schemas/b2-config-bucketInfo.schema.json b/resources/schemas/b2-config-bucketInfo.schema.json new file mode 100644 index 00000000..0d93c80e --- /dev/null +++ b/resources/schemas/b2-config-bucketInfo.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "vscode://schemas/b2-config/bucketInfo", + "title": "Backblaze B2 Bucket Info", + "description": "Custom key-value metadata stored on a Backblaze B2 bucket.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {} +} diff --git a/resources/schemas/b2-config-cors.schema.json b/resources/schemas/b2-config-cors.schema.json new file mode 100644 index 00000000..78de8827 --- /dev/null +++ b/resources/schemas/b2-config-cors.schema.json @@ -0,0 +1,85 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "vscode://schemas/b2-config/cors", + "title": "Backblaze B2 CORS Rules", + "description": "CORS rules for browser access to a Backblaze B2 bucket.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "corsRuleName": { + "type": "string", + "description": "Unique name for this CORS rule." + }, + "allowedOrigins": { + "type": "array", + "description": "Origins allowed to make cross-origin requests.", + "items": { + "type": "string" + } + }, + "allowedOperations": { + "type": "array", + "description": "B2 or S3 operations permitted by this CORS rule.", + "items": { + "type": "string", + "enum": [ + "b2_download_file_by_id", + "b2_download_file_by_name", + "b2_upload_file", + "b2_upload_part", + "s3_delete", + "s3_get", + "s3_head", + "s3_post", + "s3_put" + ] + }, + "uniqueItems": true + }, + "allowedHeaders": { + "description": "Request headers allowed in preflight requests, or null if none are allowed.", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "exposeHeaders": { + "description": "Response headers exposed to browsers, or null if none are exposed.", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "maxAgeSeconds": { + "type": "integer", + "minimum": 0, + "description": "Maximum time browsers may cache the preflight response." + } + }, + "required": [ + "corsRuleName", + "allowedOrigins", + "allowedOperations", + "allowedHeaders", + "exposeHeaders", + "maxAgeSeconds" + ] + }, + "default": [] +} diff --git a/resources/schemas/b2-config-lifecycle.schema.json b/resources/schemas/b2-config-lifecycle.schema.json new file mode 100644 index 00000000..5e5e45a5 --- /dev/null +++ b/resources/schemas/b2-config-lifecycle.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "vscode://schemas/b2-config/lifecycle", + "title": "Backblaze B2 Lifecycle Rules", + "description": "Lifecycle rules for a Backblaze B2 bucket.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "fileNamePrefix": { + "type": "string", + "description": "File name prefix this lifecycle rule applies to. Use an empty string to match every object." + }, + "daysFromUploadingToHiding": { + "description": "Days after upload before B2 hides matching objects. Use null to disable automatic hiding.", + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "daysFromHidingToDeleting": { + "description": "Days after an object is hidden before B2 deletes it. Use null to keep hidden versions.", + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + } + }, + "required": ["fileNamePrefix", "daysFromUploadingToHiding", "daysFromHidingToDeleting"] + }, + "default": [] +} diff --git a/resources/schemas/b2-config-notifications.schema.json b/resources/schemas/b2-config-notifications.schema.json new file mode 100644 index 00000000..84e49c57 --- /dev/null +++ b/resources/schemas/b2-config-notifications.schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "vscode://schemas/b2-config/notifications", + "title": "Backblaze B2 Event Notification Rules", + "description": "Event notification rules for a Backblaze B2 bucket. Masked secret values are preserved when left unchanged.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Unique name for this notification rule." + }, + "eventTypes": { + "type": "array", + "description": "B2 event types that trigger this rule.", + "items": { + "type": "string", + "enum": [ + "b2:ObjectCreated:*", + "b2:ObjectCreated:Upload", + "b2:ObjectCreated:MultipartUpload", + "b2:ObjectCreated:Copy", + "b2:ObjectCreated:Replica", + "b2:ObjectCreated:Hide", + "b2:ObjectDeleted:*", + "b2:ObjectDeleted:Delete", + "b2:ObjectDeleted:LifecycleRule" + ] + }, + "uniqueItems": true + }, + "isEnabled": { + "type": "boolean", + "description": "Whether this rule is actively sending notifications." + }, + "isSuspended": { + "type": "boolean", + "description": "Whether B2 has suspended this rule due to delivery failures." + }, + "objectNamePrefix": { + "type": "string", + "description": "Only events for objects with this prefix trigger the rule. Use an empty string to match every object." + }, + "suspensionReason": { + "type": "string", + "description": "Reason for suspension, or an empty string if not suspended." + }, + "targetConfiguration": { + "type": "object", + "additionalProperties": true, + "properties": { + "targetType": { + "type": "string", + "description": "Webhook target type, usually url." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Webhook URL that receives event notifications." + }, + "hmacSha256SigningSecret": { + "type": "string", + "description": "Optional HMAC-SHA256 signing secret. Leave __B2_CONFIG_MASKED_SECRET__ unchanged to preserve the existing secret." + }, + "hmacSha256": { + "type": "string", + "description": "Optional HMAC-SHA256 signing secret. Leave __B2_CONFIG_MASKED_SECRET__ unchanged to preserve the existing secret." + }, + "customHeaders": { + "type": "object", + "description": "Optional custom webhook request headers. Authorization header values are masked on read.", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["targetType", "url"] + } + }, + "required": [ + "name", + "eventTypes", + "isEnabled", + "isSuspended", + "objectNamePrefix", + "suspensionReason", + "targetConfiguration" + ] + }, + "default": [] +} diff --git a/src/extension.ts b/src/extension.ts index 092e4387..c992eec4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -23,6 +23,10 @@ import { withTimeout } from "./services/transferTimeout"; import { AuthService } from "./services/authService"; import { cleanupStaleTempFileCache, TempFileManager } from "./services/tempFileManager"; import { B2TreeProvider } from "./providers/b2TreeProvider"; +import { + B2_CONFIG_SCHEME, + B2ConfigFileSystemProvider, +} from "./providers/b2ConfigFileSystemProvider"; import { B2StatusBar } from "./ui/statusBar"; import { registerCommands } from "./commands"; import { registerB2Tools } from "./tools/registration"; @@ -220,7 +224,16 @@ export async function activate(context: vscode.ExtensionContext): Promise // 4. Status bar const statusBar = new B2StatusBar(authService); - // 5. Register commands + // 5. Register virtual bucket-configuration documents + const configFileSystemProvider = new B2ConfigFileSystemProvider(() => currentClient); + context.subscriptions.push( + vscode.workspace.registerFileSystemProvider(B2_CONFIG_SCHEME, configFileSystemProvider, { + isCaseSensitive: true, + }), + configFileSystemProvider, + ); + + // 6. Register commands const setAuthenticatedClient = createAuthenticatedClientSetter(); registerCommands({ context, @@ -233,11 +246,11 @@ export async function activate(context: vscode.ExtensionContext): Promise }); registerB2Tools(context, () => currentClient); - // 6. Track disposables + // 7. Track disposables context.subscriptions.push(treeView, statusBar, authService, tempFileManager); scheduleTempCleanups(context); - // 7. Auto-auth: try to resolve stored/env credentials + // 8. Auto-auth: try to resolve stored/env credentials try { const credentials = await authService.resolveCredentials(); if (credentials) { diff --git a/src/providers/b2ConfigFileSystemProvider.ts b/src/providers/b2ConfigFileSystemProvider.ts new file mode 100644 index 00000000..b2f0801e --- /dev/null +++ b/src/providers/b2ConfigFileSystemProvider.ts @@ -0,0 +1,369 @@ +/** + * FileSystemProvider for editable Backblaze B2 bucket configuration JSON. + * + * @module providers/b2ConfigFileSystemProvider + */ + +import * as vscode from "vscode"; +import type { + BucketInfo, + CorsRule, + EventNotificationRule, + GetBucketNotificationRulesResponse, + LifecycleRule, +} from "@backblaze-labs/b2-sdk"; +import { formatB2UserMessage, isBucketRevisionConflict } from "../errors"; +import { + B2_CONFIG_KINDS, + B2_CONFIG_SCHEME, + b2ConfigFileName, + cloneB2ConfigJson, + maskB2ConfigForRead, + mergeMaskedB2Config, + parseB2ConfigPath, + prettyB2ConfigJson, + stableB2ConfigJson, + validateB2ConfigJson, + type B2ConfigKind, + type B2ConfigLocation, +} from "./b2ConfigJson"; + +type BucketConfigUpdate = { + readonly bucketInfo?: Record; + readonly corsRules?: CorsRule[]; + readonly lifecycleRules?: LifecycleRule[]; + readonly ifRevisionIs?: number; +}; + +export interface B2ConfigBucket { + readonly name: string; + readonly info: Pick< + BucketInfo, + "bucketName" | "bucketInfo" | "corsRules" | "lifecycleRules" | "revision" + >; + update(options: BucketConfigUpdate): Promise; + getNotificationRules(): Promise; + setNotificationRules(rules: EventNotificationRule[]): Promise; +} + +export interface B2ConfigClient { + getBucket(bucketName: string): Promise; +} + +interface B2ConfigCacheEntry extends B2ConfigLocation { + readonly originalConfig: unknown; + readonly originalFingerprint: string; + readonly revision: number; + readonly mtime: number; + readonly size: number; +} + +class B2ConfigConflictError extends Error { + constructor(message: string) { + super(message); + this.name = "B2ConfigConflictError"; + } +} + +export function buildB2ConfigUri(bucketName: string, kind: B2ConfigKind): vscode.Uri { + return vscode.Uri.from({ + scheme: B2_CONFIG_SCHEME, + path: `/${bucketName}/${b2ConfigFileName(kind)}`, + }); +} + +export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vscode.Disposable { + private readonly changeEmitter = new vscode.EventEmitter(); + private readonly cache = new Map(); + + readonly onDidChangeFile = this.changeEmitter.event; + + constructor(private readonly getClient: () => B2ConfigClient | null) {} + + watch(): vscode.Disposable { + return new vscode.Disposable(() => {}); + } + + stat(uri: vscode.Uri): vscode.FileStat { + if (uri.scheme !== B2_CONFIG_SCHEME) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const segments = uri.path.split("/").filter(Boolean); + if (segments.length === 0 || segments.length === 1) { + return { + type: vscode.FileType.Directory, + ctime: 0, + mtime: Date.now(), + size: 0, + }; + } + + this.parseUri(uri); + const cacheEntry = this.cache.get(this.cacheKey(uri)); + return { + type: vscode.FileType.File, + ctime: 0, + mtime: cacheEntry?.mtime ?? Date.now(), + size: cacheEntry?.size ?? Buffer.byteLength(prettyB2ConfigJson([])), + }; + } + + readDirectory(uri: vscode.Uri): [string, vscode.FileType][] { + if (uri.scheme !== B2_CONFIG_SCHEME) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const segments = uri.path.split("/").filter(Boolean); + if (segments.length === 0) { + return []; + } + if (segments.length === 1) { + return B2_CONFIG_KINDS.map((kind) => [b2ConfigFileName(kind), vscode.FileType.File]); + } + + throw vscode.FileSystemError.FileNotFound(uri); + } + + createDirectory(uri: vscode.Uri): void { + throw vscode.FileSystemError.NoPermissions(uri); + } + + async readFile(uri: vscode.Uri): Promise { + const location = this.parseUri(uri); + const bucket = await this.resolveBucket(location.bucketName, uri); + const { config, revision } = await this.readLiveConfig(bucket, location.kind); + const maskedConfig = maskB2ConfigForRead(location.kind, config); + const bytes = Buffer.from(prettyB2ConfigJson(maskedConfig), "utf8"); + + this.cache.set(this.cacheKey(uri), { + ...location, + originalConfig: cloneB2ConfigJson(config), + originalFingerprint: stableB2ConfigJson(config), + revision, + mtime: Date.now(), + size: bytes.byteLength, + }); + + return bytes; + } + + async writeFile( + uri: vscode.Uri, + content: Uint8Array, + options: { readonly create: boolean; readonly overwrite: boolean }, + ): Promise { + const location = this.parseUri(uri); + const key = this.cacheKey(uri); + const cacheEntry = this.cache.get(key); + if (!cacheEntry && !options.create) { + await this.rejectWrite( + `B2: Open or reload ${b2ConfigFileName(location.kind)} before saving it.`, + ); + } + if (cacheEntry && !options.overwrite) { + throw vscode.FileSystemError.FileExists(uri); + } + + const parsedConfig = await this.parseEditedJson(location, content); + const validationError = validateB2ConfigJson(location.kind, parsedConfig); + if (validationError) { + await this.rejectWrite(`B2: ${validationError}`); + } + + const snapshot = cacheEntry ?? (await this.createWriteSnapshot(uri, location)); + const mergedConfig = mergeMaskedB2Config(location.kind, parsedConfig, snapshot.originalConfig); + const bucket = await this.resolveBucket(location.bucketName, uri); + + try { + const updated = await this.persistConfig(bucket, location.kind, mergedConfig, snapshot); + const updatedBytes = Buffer.from( + prettyB2ConfigJson(maskB2ConfigForRead(location.kind, updated.config)), + "utf8", + ); + this.cache.set(key, { + ...location, + originalConfig: cloneB2ConfigJson(updated.config), + originalFingerprint: stableB2ConfigJson(updated.config), + revision: updated.revision, + mtime: Date.now(), + size: updatedBytes.byteLength, + }); + this.changeEmitter.fire([{ type: vscode.FileChangeType.Changed, uri }]); + } catch (error) { + if (error instanceof B2ConfigConflictError) { + await this.rejectWrite(error.message); + } + if (isBucketRevisionConflict(error)) { + await this.rejectWrite(this.conflictMessage(location), error); + } + await this.rejectWrite( + `B2: Failed to save ${b2ConfigFileName(location.kind)} for "${location.bucketName}". ${formatB2UserMessage(error)}`, + error, + ); + } + } + + delete(uri: vscode.Uri): void { + throw vscode.FileSystemError.NoPermissions(uri); + } + + rename(_oldUri: vscode.Uri, newUri: vscode.Uri): void { + throw vscode.FileSystemError.NoPermissions(newUri); + } + + dispose(): void { + this.changeEmitter.dispose(); + this.cache.clear(); + } + + private parseUri(uri: vscode.Uri): B2ConfigLocation { + if (uri.scheme !== B2_CONFIG_SCHEME) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const location = parseB2ConfigPath(uri.path); + if (!location) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + return location; + } + + private cacheKey(uri: vscode.Uri): string { + return uri.toString(); + } + + private async resolveBucket(bucketName: string, uri: vscode.Uri): Promise { + const client = this.getClient(); + if (!client) { + throw vscode.FileSystemError.Unavailable("B2: Not authenticated."); + } + + const bucket = await client.getBucket(bucketName); + if (!bucket) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + return bucket; + } + + private async readLiveConfig( + bucket: B2ConfigBucket, + kind: B2ConfigKind, + ): Promise<{ readonly config: unknown; readonly revision: number }> { + const revision = bucket.info.revision; + if (typeof revision !== "number") { + throw new Error("B2 bucket metadata is missing a revision."); + } + + switch (kind) { + case "bucketInfo": + return { config: bucket.info.bucketInfo, revision }; + case "cors": + return { config: bucket.info.corsRules, revision }; + case "lifecycle": + return { config: bucket.info.lifecycleRules, revision }; + case "notifications": { + const response = await bucket.getNotificationRules(); + return { config: response.eventNotificationRules, revision }; + } + } + } + + private async parseEditedJson(location: B2ConfigLocation, content: Uint8Array): Promise { + const text = Buffer.from(content).toString("utf8"); + try { + return JSON.parse(text); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return this.rejectWrite( + `B2: ${b2ConfigFileName(location.kind)} contains invalid JSON. ${detail}`, + ); + } + } + + private async createWriteSnapshot( + uri: vscode.Uri, + location: B2ConfigLocation, + ): Promise { + const bytes = await this.readFile(uri); + const entry = this.cache.get(this.cacheKey(uri)); + if (!entry) { + throw new Error(`B2: Could not prepare ${b2ConfigFileName(location.kind)} for saving.`); + } + return { ...entry, size: bytes.byteLength }; + } + + private async persistConfig( + bucket: B2ConfigBucket, + kind: B2ConfigKind, + config: unknown, + snapshot: B2ConfigCacheEntry, + ): Promise<{ readonly config: unknown; readonly revision: number }> { + switch (kind) { + case "bucketInfo": { + const updated = await bucket.update({ + bucketInfo: config as Record, + ifRevisionIs: snapshot.revision, + }); + return { config: updated.bucketInfo, revision: updated.revision }; + } + case "cors": { + const updated = await bucket.update({ + corsRules: config as CorsRule[], + ifRevisionIs: snapshot.revision, + }); + return { config: updated.corsRules, revision: updated.revision }; + } + case "lifecycle": { + const updated = await bucket.update({ + lifecycleRules: config as LifecycleRule[], + ifRevisionIs: snapshot.revision, + }); + return { config: updated.lifecycleRules, revision: updated.revision }; + } + case "notifications": + await this.assertNotificationSnapshotCurrent(bucket, snapshot); + return this.persistNotificationRules(bucket, config, snapshot.revision); + } + } + + private async persistNotificationRules( + bucket: B2ConfigBucket, + config: unknown, + revision: number, + ): Promise<{ readonly config: unknown; readonly revision: number }> { + await bucket.setNotificationRules(config as EventNotificationRule[]); + return { config, revision }; + } + + private async assertNotificationSnapshotCurrent( + bucket: B2ConfigBucket, + snapshot: B2ConfigCacheEntry, + ): Promise { + if (bucket.info.revision !== snapshot.revision) { + throw new B2ConfigConflictError(this.conflictMessage(snapshot)); + } + + const current = await bucket.getNotificationRules(); + if (stableB2ConfigJson(current.eventNotificationRules) !== snapshot.originalFingerprint) { + throw new B2ConfigConflictError(this.conflictMessage(snapshot)); + } + } + + private conflictMessage(location: B2ConfigLocation): string { + return `B2: Bucket "${location.bucketName}" changed after ${b2ConfigFileName(location.kind)} was opened. Reload the b2-config document and apply your edits again.`; + } + + private async rejectWrite(message: string, cause?: unknown): Promise { + await vscode.window.showErrorMessage(message); + if (cause instanceof Error) { + throw cause; + } + throw new Error(message); + } +} + +export { B2_CONFIG_FILE_EXTENSION } from "./b2ConfigJson"; +export { B2_CONFIG_SCHEME }; diff --git a/src/providers/b2ConfigJson.ts b/src/providers/b2ConfigJson.ts new file mode 100644 index 00000000..5bfd2fd5 --- /dev/null +++ b/src/providers/b2ConfigJson.ts @@ -0,0 +1,250 @@ +/** + * Shared JSON helpers for virtual B2 bucket-configuration documents. + * + * @module providers/b2ConfigJson + */ + +export const B2_CONFIG_SCHEME = "b2-config"; +export const B2_CONFIG_FILE_EXTENSION = ".json"; +export const B2_CONFIG_MASKED_SECRET = "__B2_CONFIG_MASKED_SECRET__"; + +export const B2_CONFIG_KINDS = ["lifecycle", "cors", "notifications", "bucketInfo"] as const; + +export type B2ConfigKind = (typeof B2_CONFIG_KINDS)[number]; + +export interface B2ConfigLocation { + readonly bucketName: string; + readonly kind: B2ConfigKind; +} + +type MutableJsonRecord = Record; + +export function isB2ConfigKind(value: string): value is B2ConfigKind { + return B2_CONFIG_KINDS.includes(value as B2ConfigKind); +} + +export function parseB2ConfigPath(path: string): B2ConfigLocation | undefined { + const segments = path.split("/").filter(Boolean); + if (segments.length !== 2) { + return undefined; + } + + const [bucketName, fileName] = segments; + if (!bucketName || !fileName.endsWith(B2_CONFIG_FILE_EXTENSION)) { + return undefined; + } + + const kind = fileName.slice(0, -B2_CONFIG_FILE_EXTENSION.length); + if (!isB2ConfigKind(kind)) { + return undefined; + } + + return { bucketName, kind }; +} + +export function b2ConfigFileName(kind: B2ConfigKind): string { + return `${kind}${B2_CONFIG_FILE_EXTENSION}`; +} + +export function prettyB2ConfigJson(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +export function cloneB2ConfigJson(value: unknown): unknown { + return JSON.parse(JSON.stringify(value)); +} + +function isRecord(value: unknown): value is MutableJsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isAuthorizationHeader(headerName: string): boolean { + return headerName.toLowerCase() === "authorization"; +} + +function findHeaderKey(headers: MutableJsonRecord, headerName: string): string | undefined { + return Object.keys(headers).find( + (candidate) => candidate.toLowerCase() === headerName.toLowerCase(), + ); +} + +function maskNotificationRules(value: unknown): unknown { + const cloned = cloneB2ConfigJson(value); + if (!Array.isArray(cloned)) { + return cloned; + } + + for (const rule of cloned) { + if (!isRecord(rule) || !isRecord(rule.targetConfiguration)) { + continue; + } + + for (const secretKey of ["hmacSha256SigningSecret", "hmacSha256"]) { + if (typeof rule.targetConfiguration[secretKey] === "string") { + rule.targetConfiguration[secretKey] = B2_CONFIG_MASKED_SECRET; + } + } + + const customHeaders = rule.targetConfiguration.customHeaders; + if (!isRecord(customHeaders)) { + continue; + } + + for (const [headerName, headerValue] of Object.entries(customHeaders)) { + if (isAuthorizationHeader(headerName) && typeof headerValue === "string") { + customHeaders[headerName] = B2_CONFIG_MASKED_SECRET; + } + } + } + + return cloned; +} + +export function maskB2ConfigForRead(kind: B2ConfigKind, value: unknown): unknown { + return kind === "notifications" ? maskNotificationRules(value) : cloneB2ConfigJson(value); +} + +function mergeNotificationSecrets(edited: unknown, original: unknown): unknown { + const merged = cloneB2ConfigJson(edited); + if (!Array.isArray(merged) || !Array.isArray(original)) { + return merged; + } + + for (const [index, rule] of merged.entries()) { + const originalRule = original[index]; + if ( + !isRecord(rule) || + !isRecord(originalRule) || + !isRecord(rule.targetConfiguration) || + !isRecord(originalRule.targetConfiguration) + ) { + continue; + } + + for (const secretKey of ["hmacSha256SigningSecret", "hmacSha256"]) { + if (rule.targetConfiguration[secretKey] !== B2_CONFIG_MASKED_SECRET) { + continue; + } + const originalSecret = originalRule.targetConfiguration[secretKey]; + if (typeof originalSecret === "string") { + rule.targetConfiguration[secretKey] = originalSecret; + } + } + + const editedHeaders = rule.targetConfiguration.customHeaders; + const originalHeaders = originalRule.targetConfiguration.customHeaders; + if (!isRecord(editedHeaders) || !isRecord(originalHeaders)) { + continue; + } + + for (const [headerName, headerValue] of Object.entries(editedHeaders)) { + if (!isAuthorizationHeader(headerName) || headerValue !== B2_CONFIG_MASKED_SECRET) { + continue; + } + const originalHeaderName = findHeaderKey(originalHeaders, headerName); + if ( + originalHeaderName !== undefined && + typeof originalHeaders[originalHeaderName] === "string" + ) { + editedHeaders[headerName] = originalHeaders[originalHeaderName]; + } + } + } + + return merged; +} + +export function mergeMaskedB2Config( + kind: B2ConfigKind, + edited: unknown, + original: unknown, +): unknown { + return kind === "notifications" + ? mergeNotificationSecrets(edited, original) + : cloneB2ConfigJson(edited); +} + +function validateArrayConfig(kind: B2ConfigKind, value: unknown): string | undefined { + if (Array.isArray(value)) { + return undefined; + } + return `${b2ConfigFileName(kind)} must contain a JSON array.`; +} + +function validateBucketInfo(value: unknown): string | undefined { + if (!isRecord(value)) { + return "bucketInfo.json must contain a JSON object."; + } + + for (const [key, item] of Object.entries(value)) { + if (typeof item !== "string") { + return `bucketInfo.${key} must be a string.`; + } + } + + return undefined; +} + +function validateNotificationHeaders(value: unknown): string | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + for (const [index, rule] of value.entries()) { + if (!isRecord(rule)) { + return `notifications[${index}] must be an object.`; + } + const targetConfiguration = rule.targetConfiguration; + if (targetConfiguration === undefined) { + continue; + } + if (!isRecord(targetConfiguration)) { + return `notifications[${index}].targetConfiguration must be an object.`; + } + const customHeaders = targetConfiguration.customHeaders; + if (customHeaders === undefined) { + continue; + } + if (!isRecord(customHeaders)) { + return `notifications[${index}].targetConfiguration.customHeaders must be an object.`; + } + for (const [headerName, headerValue] of Object.entries(customHeaders)) { + if (typeof headerValue !== "string") { + return `notifications[${index}].targetConfiguration.customHeaders.${headerName} must be a string.`; + } + } + } + + return undefined; +} + +export function validateB2ConfigJson(kind: B2ConfigKind, value: unknown): string | undefined { + switch (kind) { + case "bucketInfo": + return validateBucketInfo(value); + case "cors": + case "lifecycle": + return validateArrayConfig(kind, value); + case "notifications": + return validateArrayConfig(kind, value) ?? validateNotificationHeaders(value); + } +} + +function normalizeForStableJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(normalizeForStableJson); + } + if (!isRecord(value)) { + return value; + } + + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, normalizeForStableJson(value[key])]), + ); +} + +export function stableB2ConfigJson(value: unknown): string { + return JSON.stringify(normalizeForStableJson(value)); +} diff --git a/src/test/unit/b2ConfigJson.test.ts b/src/test/unit/b2ConfigJson.test.ts new file mode 100644 index 00000000..67fc0709 --- /dev/null +++ b/src/test/unit/b2ConfigJson.test.ts @@ -0,0 +1,130 @@ +/** + * Unit tests for B2 bucket-configuration JSON helpers. + * + * @module test/unit/b2ConfigJson + */ + +import * as assert from "node:assert/strict"; +import test from "node:test"; +import { + B2_CONFIG_MASKED_SECRET, + maskB2ConfigForRead, + mergeMaskedB2Config, + parseB2ConfigPath, + stableB2ConfigJson, + validateB2ConfigJson, +} from "../../providers/b2ConfigJson"; + +test("parses b2-config bucket config document paths", () => { + assert.deepEqual(parseB2ConfigPath("/my-bucket/lifecycle.json"), { + bucketName: "my-bucket", + kind: "lifecycle", + }); + assert.deepEqual(parseB2ConfigPath("/my-bucket/cors.json"), { + bucketName: "my-bucket", + kind: "cors", + }); + assert.deepEqual(parseB2ConfigPath("/my-bucket/notifications.json"), { + bucketName: "my-bucket", + kind: "notifications", + }); + assert.deepEqual(parseB2ConfigPath("/my-bucket/bucketInfo.json"), { + bucketName: "my-bucket", + kind: "bucketInfo", + }); + + assert.equal(parseB2ConfigPath("/my-bucket/unknown.json"), undefined); + assert.equal(parseB2ConfigPath("/my-bucket/lifecycle.txt"), undefined); + assert.equal(parseB2ConfigPath("/too/many/lifecycle.json"), undefined); +}); + +test("masks notification signing secrets and authorization headers on read", () => { + const original = [ + { + name: "webhook", + targetConfiguration: { + targetType: "url", + url: "https://example.com/b2", + hmacSha256SigningSecret: "signing-secret", + hmacSha256: "legacy-secret", + customHeaders: { + Authorization: "Bearer secret", + "X-Trace": "keep-visible", + }, + }, + }, + ]; + + const masked = maskB2ConfigForRead("notifications", original) as typeof original; + + assert.equal(masked[0].targetConfiguration.hmacSha256SigningSecret, B2_CONFIG_MASKED_SECRET); + assert.equal(masked[0].targetConfiguration.hmacSha256, B2_CONFIG_MASKED_SECRET); + assert.equal(masked[0].targetConfiguration.customHeaders.Authorization, B2_CONFIG_MASKED_SECRET); + assert.equal(masked[0].targetConfiguration.customHeaders["X-Trace"], "keep-visible"); + assert.equal(original[0].targetConfiguration.hmacSha256SigningSecret, "signing-secret"); +}); + +test("merges unchanged notification masks back to the original secret values", () => { + const original = [ + { + targetConfiguration: { + hmacSha256SigningSecret: "signing-secret", + customHeaders: { + Authorization: "Bearer secret", + }, + }, + }, + ]; + const edited = maskB2ConfigForRead("notifications", original) as typeof original; + + const merged = mergeMaskedB2Config("notifications", edited, original); + + assert.deepEqual(merged, original); +}); + +test("changed notification secrets replace originals while unchanged masks are preserved", () => { + const original = [ + { + targetConfiguration: { + hmacSha256SigningSecret: "old-secret", + customHeaders: { + Authorization: "Bearer old", + }, + }, + }, + ]; + const edited = maskB2ConfigForRead("notifications", original) as typeof original; + edited[0].targetConfiguration.hmacSha256SigningSecret = "new-secret"; + + const merged = mergeMaskedB2Config("notifications", edited, original) as typeof original; + + assert.equal(merged[0].targetConfiguration.hmacSha256SigningSecret, "new-secret"); + assert.equal(merged[0].targetConfiguration.customHeaders.Authorization, "Bearer old"); +}); + +test("validates top-level config shapes before save", () => { + assert.equal(validateB2ConfigJson("lifecycle", []), undefined); + assert.equal(validateB2ConfigJson("cors", []), undefined); + assert.equal(validateB2ConfigJson("notifications", []), undefined); + assert.equal(validateB2ConfigJson("bucketInfo", { team: "platform" }), undefined); + + assert.match(validateB2ConfigJson("lifecycle", {}) ?? "", /array/u); + assert.match(validateB2ConfigJson("bucketInfo", []) ?? "", /object/u); + assert.match(validateB2ConfigJson("bucketInfo", { team: 1 }) ?? "", /string/u); + assert.match( + validateB2ConfigJson("notifications", [ + { targetConfiguration: { customHeaders: { Authorization: 1 } } }, + ]) ?? "", + /must be a string/u, + ); +}); + +test("stable config JSON ignores object key insertion order", () => { + assert.equal( + stableB2ConfigJson({ b: 1, a: { d: 4, c: 3 } }), + stableB2ConfigJson({ + a: { c: 3, d: 4 }, + b: 1, + }), + ); +}); From 079d68a219f4e3d7526ae94268fe3444123508c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Fri, 3 Jul 2026 09:08:25 -0500 Subject: [PATCH 2/8] fix: harden B2 config secrets --- .../b2-config-notifications.schema.json | 2 +- src/extension.ts | 13 +- src/providers/b2ConfigFileSystemProvider.ts | 324 ++++++++++-- src/providers/b2ConfigJson.ts | 493 +++++++++++++++--- src/test/unit/b2ConfigJson.test.ts | 389 ++++++++++++-- 5 files changed, 1049 insertions(+), 172 deletions(-) diff --git a/resources/schemas/b2-config-notifications.schema.json b/resources/schemas/b2-config-notifications.schema.json index 84e49c57..a0a92e1d 100644 --- a/resources/schemas/b2-config-notifications.schema.json +++ b/resources/schemas/b2-config-notifications.schema.json @@ -70,7 +70,7 @@ }, "customHeaders": { "type": "object", - "description": "Optional custom webhook request headers. Authorization header values are masked on read.", + "description": "Optional custom webhook request headers. Header values are masked on read.", "additionalProperties": { "type": "string" } diff --git a/src/extension.ts b/src/extension.ts index c992eec4..521777e3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -231,10 +231,21 @@ export async function activate(context: vscode.ExtensionContext): Promise isCaseSensitive: true, }), configFileSystemProvider, + vscode.workspace.onDidCloseTextDocument((document) => { + if (document.uri.scheme === B2_CONFIG_SCHEME) { + configFileSystemProvider.deleteCacheEntry(document.uri); + } + }), ); // 6. Register commands - const setAuthenticatedClient = createAuthenticatedClientSetter(); + const setAuthenticatedClient = createAuthenticatedClientSetter( + scheduleAuthenticatedCleanups, + (client) => { + currentClient = client; + configFileSystemProvider.clearCache(); + }, + ); registerCommands({ context, authService, diff --git a/src/providers/b2ConfigFileSystemProvider.ts b/src/providers/b2ConfigFileSystemProvider.ts index b2f0801e..b4fb7bf8 100644 --- a/src/providers/b2ConfigFileSystemProvider.ts +++ b/src/providers/b2ConfigFileSystemProvider.ts @@ -13,21 +13,27 @@ import type { LifecycleRule, } from "@backblaze-labs/b2-sdk"; import { formatB2UserMessage, isBucketRevisionConflict } from "../errors"; +import { log, logError } from "../logger"; import { B2_CONFIG_KINDS, B2_CONFIG_SCHEME, b2ConfigFileName, - cloneB2ConfigJson, + createB2ConfigSecretSnapshot, + fingerprintB2ConfigJson, maskB2ConfigForRead, mergeMaskedB2Config, parseB2ConfigPath, prettyB2ConfigJson, - stableB2ConfigJson, validateB2ConfigJson, type B2ConfigKind, type B2ConfigLocation, + type B2ConfigSecretSnapshot, } from "./b2ConfigJson"; +export const B2_CONFIG_REMOTE_TIMEOUT_MS = 60_000; +export const B2_CONFIG_CACHE_TTL_MS = 15 * 60 * 1000; +export const B2_CONFIG_CACHE_MAX_ENTRIES = 32; + type BucketConfigUpdate = { readonly bucketInfo?: Record; readonly corsRules?: CorsRule[]; @@ -51,11 +57,13 @@ export interface B2ConfigClient { } interface B2ConfigCacheEntry extends B2ConfigLocation { - readonly originalConfig: unknown; readonly originalFingerprint: string; + readonly secretSnapshot?: B2ConfigSecretSnapshot; readonly revision: number; readonly mtime: number; readonly size: number; + readonly expiresAt: number; + readonly lastAccessedAt: number; } class B2ConfigConflictError extends Error { @@ -65,6 +73,13 @@ class B2ConfigConflictError extends Error { } } +class B2ConfigRemoteTimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = "B2ConfigRemoteTimeoutError"; + } +} + export function buildB2ConfigUri(bucketName: string, kind: B2ConfigKind): vscode.Uri { return vscode.Uri.from({ scheme: B2_CONFIG_SCHEME, @@ -100,7 +115,7 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs } this.parseUri(uri); - const cacheEntry = this.cache.get(this.cacheKey(uri)); + const cacheEntry = this.getCacheEntry(this.cacheKey(uri)); return { type: vscode.FileType.File, ctime: 0, @@ -131,21 +146,21 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs async readFile(uri: vscode.Uri): Promise { const location = this.parseUri(uri); - const bucket = await this.resolveBucket(location.bucketName, uri); - const { config, revision } = await this.readLiveConfig(bucket, location.kind); - const maskedConfig = maskB2ConfigForRead(location.kind, config); - const bytes = Buffer.from(prettyB2ConfigJson(maskedConfig), "utf8"); + try { + const bucket = await this.resolveBucket(location, uri); + const { config, revision } = await this.readLiveConfig(bucket, location); + const maskedConfig = maskB2ConfigForRead(location.kind, config); + const bytes = Buffer.from(prettyB2ConfigJson(maskedConfig), "utf8"); - this.cache.set(this.cacheKey(uri), { - ...location, - originalConfig: cloneB2ConfigJson(config), - originalFingerprint: stableB2ConfigJson(config), - revision, - mtime: Date.now(), - size: bytes.byteLength, - }); + this.rememberConfigSnapshot(uri, location, config, revision, bytes.byteLength); - return bytes; + return bytes; + } catch (error) { + if (error instanceof B2ConfigRemoteTimeoutError) { + await vscode.window.showErrorMessage(`B2: ${error.message}`); + } + throw error; + } } async writeFile( @@ -155,7 +170,7 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs ): Promise { const location = this.parseUri(uri); const key = this.cacheKey(uri); - const cacheEntry = this.cache.get(key); + const cacheEntry = this.getCacheEntry(key); if (!cacheEntry && !options.create) { await this.rejectWrite( `B2: Open or reload ${b2ConfigFileName(location.kind)} before saving it.`, @@ -172,28 +187,36 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs } const snapshot = cacheEntry ?? (await this.createWriteSnapshot(uri, location)); - const mergedConfig = mergeMaskedB2Config(location.kind, parsedConfig, snapshot.originalConfig); - const bucket = await this.resolveBucket(location.bucketName, uri); + let mergedConfig: unknown; + try { + mergedConfig = mergeMaskedB2Config(location.kind, parsedConfig, snapshot.secretSnapshot); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + await this.rejectWrite(`B2: ${detail}`); + } try { - const updated = await this.persistConfig(bucket, location.kind, mergedConfig, snapshot); + const bucket = await this.resolveBucket(location, uri); + const updated = await this.persistConfig(bucket, location, mergedConfig, snapshot); const updatedBytes = Buffer.from( prettyB2ConfigJson(maskB2ConfigForRead(location.kind, updated.config)), "utf8", ); - this.cache.set(key, { - ...location, - originalConfig: cloneB2ConfigJson(updated.config), - originalFingerprint: stableB2ConfigJson(updated.config), - revision: updated.revision, - mtime: Date.now(), - size: updatedBytes.byteLength, - }); + this.rememberConfigSnapshot( + uri, + location, + updated.config, + updated.revision, + updatedBytes.byteLength, + ); this.changeEmitter.fire([{ type: vscode.FileChangeType.Changed, uri }]); } catch (error) { if (error instanceof B2ConfigConflictError) { await this.rejectWrite(error.message); } + if (error instanceof B2ConfigRemoteTimeoutError) { + await this.rejectWrite(`B2: ${error.message}`, error); + } if (isBucketRevisionConflict(error)) { await this.rejectWrite(this.conflictMessage(location), error); } @@ -217,6 +240,14 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs this.cache.clear(); } + clearCache(): void { + this.cache.clear(); + } + + deleteCacheEntry(uri: vscode.Uri): void { + this.cache.delete(this.cacheKey(uri)); + } + private parseUri(uri: vscode.Uri): B2ConfigLocation { if (uri.scheme !== B2_CONFIG_SCHEME) { throw vscode.FileSystemError.FileNotFound(uri); @@ -234,13 +265,76 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs return uri.toString(); } - private async resolveBucket(bucketName: string, uri: vscode.Uri): Promise { + private getCacheEntry(key: string): B2ConfigCacheEntry | undefined { + this.pruneCache(); + const entry = this.cache.get(key); + if (!entry) { + return undefined; + } + + const now = Date.now(); + if (entry.expiresAt <= now) { + this.cache.delete(key); + return undefined; + } + + const touched = { ...entry, lastAccessedAt: now }; + this.cache.set(key, touched); + return touched; + } + + private rememberConfigSnapshot( + uri: vscode.Uri, + location: B2ConfigLocation, + config: unknown, + revision: number, + size: number, + ): void { + const now = Date.now(); + this.cache.set(this.cacheKey(uri), { + ...location, + originalFingerprint: fingerprintB2ConfigJson(config), + secretSnapshot: createB2ConfigSecretSnapshot(location.kind, config), + revision, + mtime: now, + size, + expiresAt: now + B2_CONFIG_CACHE_TTL_MS, + lastAccessedAt: now, + }); + this.pruneCache(now); + } + + private pruneCache(now: number = Date.now()): void { + for (const [key, entry] of this.cache) { + if (entry.expiresAt <= now) { + this.cache.delete(key); + } + } + + if (this.cache.size <= B2_CONFIG_CACHE_MAX_ENTRIES) { + return; + } + + const entriesByAge = [...this.cache.entries()].sort( + ([, left], [, right]) => left.lastAccessedAt - right.lastAccessedAt, + ); + for (const [key] of entriesByAge.slice(0, this.cache.size - B2_CONFIG_CACHE_MAX_ENTRIES)) { + this.cache.delete(key); + } + } + + private async resolveBucket( + location: B2ConfigLocation, + uri: vscode.Uri, + ): Promise { const client = this.getClient(); if (!client) { throw vscode.FileSystemError.Unavailable("B2: Not authenticated."); } - const bucket = await client.getBucket(bucketName); + const bucket = await this.withRemoteTimeout(location, "fetch bucket metadata", () => + client.getBucket(location.bucketName), + ); if (!bucket) { throw vscode.FileSystemError.FileNotFound(uri); } @@ -250,14 +344,14 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs private async readLiveConfig( bucket: B2ConfigBucket, - kind: B2ConfigKind, + location: B2ConfigLocation, ): Promise<{ readonly config: unknown; readonly revision: number }> { const revision = bucket.info.revision; if (typeof revision !== "number") { throw new Error("B2 bucket metadata is missing a revision."); } - switch (kind) { + switch (location.kind) { case "bucketInfo": return { config: bucket.info.bucketInfo, revision }; case "cors": @@ -265,7 +359,9 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs case "lifecycle": return { config: bucket.info.lifecycleRules, revision }; case "notifications": { - const response = await bucket.getNotificationRules(); + const response = await this.withRemoteTimeout(location, "read notification rules", () => + bucket.getNotificationRules(), + ); return { config: response.eventNotificationRules, revision }; } } @@ -297,61 +393,183 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs private async persistConfig( bucket: B2ConfigBucket, - kind: B2ConfigKind, + location: B2ConfigLocation, config: unknown, snapshot: B2ConfigCacheEntry, ): Promise<{ readonly config: unknown; readonly revision: number }> { - switch (kind) { + switch (location.kind) { case "bucketInfo": { - const updated = await bucket.update({ - bucketInfo: config as Record, - ifRevisionIs: snapshot.revision, - }); + const updated = await this.withRemoteTimeout( + location, + "save bucketInfo", + () => + bucket.update({ + bucketInfo: config as Record, + ifRevisionIs: snapshot.revision, + }), + { observeLateOutcome: true }, + ); return { config: updated.bucketInfo, revision: updated.revision }; } case "cors": { - const updated = await bucket.update({ - corsRules: config as CorsRule[], - ifRevisionIs: snapshot.revision, - }); + const updated = await this.withRemoteTimeout( + location, + "save CORS rules", + () => + bucket.update({ + corsRules: config as CorsRule[], + ifRevisionIs: snapshot.revision, + }), + { observeLateOutcome: true }, + ); return { config: updated.corsRules, revision: updated.revision }; } case "lifecycle": { - const updated = await bucket.update({ - lifecycleRules: config as LifecycleRule[], - ifRevisionIs: snapshot.revision, - }); + const updated = await this.withRemoteTimeout( + location, + "save lifecycle rules", + () => + bucket.update({ + lifecycleRules: config as LifecycleRule[], + ifRevisionIs: snapshot.revision, + }), + { observeLateOutcome: true }, + ); return { config: updated.lifecycleRules, revision: updated.revision }; } case "notifications": - await this.assertNotificationSnapshotCurrent(bucket, snapshot); - return this.persistNotificationRules(bucket, config, snapshot.revision); + await this.assertNotificationSnapshotCurrent(bucket, location, snapshot); + return this.persistNotificationRules(bucket, location, config, snapshot.revision); } } private async persistNotificationRules( bucket: B2ConfigBucket, + location: B2ConfigLocation, config: unknown, revision: number, ): Promise<{ readonly config: unknown; readonly revision: number }> { - await bucket.setNotificationRules(config as EventNotificationRule[]); + try { + await this.withRemoteTimeout( + location, + "save notification rules", + () => bucket.setNotificationRules(config as EventNotificationRule[]), + { observeLateOutcome: true }, + ); + } catch (error) { + const reconciled = await this.reconcileNotificationSave(bucket, location, config, error); + if (reconciled) { + return { config: reconciled, revision }; + } + throw error; + } + return { config, revision }; } + private async reconcileNotificationSave( + bucket: B2ConfigBucket, + location: B2ConfigLocation, + intendedConfig: unknown, + originalError: unknown, + ): Promise { + try { + const current = await this.withRemoteTimeout( + location, + "re-read notification rules after failed save", + () => bucket.getNotificationRules(), + ); + if ( + fingerprintB2ConfigJson(current.eventNotificationRules) === + fingerprintB2ConfigJson(intendedConfig) + ) { + log( + `B2 config save for ${b2ConfigFileName(location.kind)} in bucket "${location.bucketName}" was reconciled after a failed response.`, + ); + return current.eventNotificationRules; + } + } catch (reconcileError) { + logError( + `Could not reconcile B2 config save for ${b2ConfigFileName(location.kind)} in bucket "${location.bucketName}" after a failed response`, + reconcileError, + ); + } + + logError( + `B2 config save for ${b2ConfigFileName(location.kind)} in bucket "${location.bucketName}" could not be reconciled after failure`, + originalError, + ); + return undefined; + } + private async assertNotificationSnapshotCurrent( bucket: B2ConfigBucket, + location: B2ConfigLocation, snapshot: B2ConfigCacheEntry, ): Promise { if (bucket.info.revision !== snapshot.revision) { throw new B2ConfigConflictError(this.conflictMessage(snapshot)); } - const current = await bucket.getNotificationRules(); - if (stableB2ConfigJson(current.eventNotificationRules) !== snapshot.originalFingerprint) { + const current = await this.withRemoteTimeout(location, "check notification rules", () => + bucket.getNotificationRules(), + ); + if (fingerprintB2ConfigJson(current.eventNotificationRules) !== snapshot.originalFingerprint) { throw new B2ConfigConflictError(this.conflictMessage(snapshot)); } } + private async withRemoteTimeout( + location: B2ConfigLocation, + operation: string, + run: (signal: AbortSignal) => Promise, + options: { readonly observeLateOutcome?: boolean } = {}, + ): Promise { + const description = `B2 config ${operation} for ${b2ConfigFileName(location.kind)} in bucket "${location.bucketName}"`; + const controller = new AbortController(); + let timer: NodeJS.Timeout | undefined; + let timedOut = false; + let timeoutError: B2ConfigRemoteTimeoutError | undefined; + const operationPromise = Promise.resolve().then(() => run(controller.signal)); + void operationPromise.catch(() => undefined); + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + timedOut = true; + timeoutError = new B2ConfigRemoteTimeoutError( + `${description} timed out after ${B2_CONFIG_REMOTE_TIMEOUT_MS} ms.`, + ); + if (!controller.signal.aborted) { + controller.abort(timeoutError); + } + reject(timeoutError); + }, B2_CONFIG_REMOTE_TIMEOUT_MS); + timer.unref?.(); + }); + + try { + return await Promise.race([operationPromise, timeout]); + } catch (error) { + if (timedOut && error === timeoutError) { + logError(`${description} timed out`, error); + if (options.observeLateOutcome) { + void operationPromise.then( + () => { + log(`${description} completed after the client-side timeout.`); + }, + (lateError) => { + logError(`${description} failed after the client-side timeout`, lateError); + }, + ); + } + } + throw error; + } finally { + if (timer) { + clearTimeout(timer); + } + } + } + private conflictMessage(location: B2ConfigLocation): string { return `B2: Bucket "${location.bucketName}" changed after ${b2ConfigFileName(location.kind)} was opened. Reload the b2-config document and apply your edits again.`; } diff --git a/src/providers/b2ConfigJson.ts b/src/providers/b2ConfigJson.ts index 5bfd2fd5..615a9f17 100644 --- a/src/providers/b2ConfigJson.ts +++ b/src/providers/b2ConfigJson.ts @@ -4,23 +4,80 @@ * @module providers/b2ConfigJson */ +import { createHash } from "node:crypto"; + export const B2_CONFIG_SCHEME = "b2-config"; export const B2_CONFIG_FILE_EXTENSION = ".json"; export const B2_CONFIG_MASKED_SECRET = "__B2_CONFIG_MASKED_SECRET__"; -export const B2_CONFIG_KINDS = ["lifecycle", "cors", "notifications", "bucketInfo"] as const; - -export type B2ConfigKind = (typeof B2_CONFIG_KINDS)[number]; +export const B2_CONFIG_KIND_DESCRIPTORS = { + lifecycle: { + fileName: "lifecycle.json", + schemaPath: "./resources/schemas/b2-config-lifecycle.schema.json", + }, + cors: { + fileName: "cors.json", + schemaPath: "./resources/schemas/b2-config-cors.schema.json", + }, + notifications: { + fileName: "notifications.json", + schemaPath: "./resources/schemas/b2-config-notifications.schema.json", + }, + bucketInfo: { + fileName: "bucketInfo.json", + schemaPath: "./resources/schemas/b2-config-bucketInfo.schema.json", + }, +} as const; + +export type B2ConfigKind = keyof typeof B2_CONFIG_KIND_DESCRIPTORS; +export const B2_CONFIG_KINDS = Object.keys(B2_CONFIG_KIND_DESCRIPTORS) as B2ConfigKind[]; export interface B2ConfigLocation { readonly bucketName: string; readonly kind: B2ConfigKind; } +interface NotificationRuleSecretSnapshot { + readonly label: string; + readonly secrets: Readonly>; + readonly customHeaders: Readonly>; +} + +export interface B2ConfigSecretSnapshot { + readonly rulesByIdentity: Readonly>; + readonly duplicateIdentities: readonly string[]; +} + type MutableJsonRecord = Record; +const NOTIFICATION_SECRET_KEYS = ["hmacSha256SigningSecret", "hmacSha256"] as const; + +const CORS_OPERATIONS = new Set([ + "b2_download_file_by_id", + "b2_download_file_by_name", + "b2_upload_file", + "b2_upload_part", + "s3_delete", + "s3_get", + "s3_head", + "s3_post", + "s3_put", +]); + +const EVENT_TYPES = new Set([ + "b2:ObjectCreated:*", + "b2:ObjectCreated:Upload", + "b2:ObjectCreated:MultipartUpload", + "b2:ObjectCreated:Copy", + "b2:ObjectCreated:Replica", + "b2:ObjectCreated:Hide", + "b2:ObjectDeleted:*", + "b2:ObjectDeleted:Delete", + "b2:ObjectDeleted:LifecycleRule", +]); + export function isB2ConfigKind(value: string): value is B2ConfigKind { - return B2_CONFIG_KINDS.includes(value as B2ConfigKind); + return Object.prototype.hasOwnProperty.call(B2_CONFIG_KIND_DESCRIPTORS, value); } export function parseB2ConfigPath(path: string): B2ConfigLocation | undefined { @@ -43,7 +100,11 @@ export function parseB2ConfigPath(path: string): B2ConfigLocation | undefined { } export function b2ConfigFileName(kind: B2ConfigKind): string { - return `${kind}${B2_CONFIG_FILE_EXTENSION}`; + return B2_CONFIG_KIND_DESCRIPTORS[kind].fileName; +} + +export function b2ConfigSchemaPath(kind: B2ConfigKind): string { + return B2_CONFIG_KIND_DESCRIPTORS[kind].schemaPath; } export function prettyB2ConfigJson(value: unknown): string { @@ -51,18 +112,72 @@ export function prettyB2ConfigJson(value: unknown): string { } export function cloneB2ConfigJson(value: unknown): unknown { - return JSON.parse(JSON.stringify(value)); + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); } function isRecord(value: unknown): value is MutableJsonRecord { return typeof value === "object" && value !== null && !Array.isArray(value); } -function isAuthorizationHeader(headerName: string): boolean { - return headerName.toLowerCase() === "authorization"; +function isIntegerAtLeast(value: unknown, minimum: number): boolean { + return Number.isInteger(value) && typeof value === "number" && value >= minimum; +} + +function validateStringArray(value: unknown, path: string): string | undefined { + if (!Array.isArray(value)) { + return `${path} must be an array.`; + } + + const invalidIndex = value.findIndex((item) => typeof item !== "string"); + return invalidIndex === -1 ? undefined : `${path}[${invalidIndex}] must be a string.`; +} + +function validateNullableStringArray(value: unknown, path: string): string | undefined { + return value === null ? undefined : validateStringArray(value, path); +} + +function validateNoUnexpectedProperties( + value: MutableJsonRecord, + allowedKeys: readonly string[], + path: string, +): string | undefined { + const allowed = new Set(allowedKeys); + const unexpected = Object.keys(value).find((key) => !allowed.has(key)); + return unexpected === undefined ? undefined : `${path}.${unexpected} is not supported.`; } -function findHeaderKey(headers: MutableJsonRecord, headerName: string): string | undefined { +function notificationRuleIdentity(rule: unknown): string | undefined { + if (!isRecord(rule) || !isRecord(rule.targetConfiguration)) { + return undefined; + } + + const { name } = rule; + const targetType = rule.targetConfiguration.targetType; + const url = rule.targetConfiguration.url; + if (typeof name !== "string" || typeof targetType !== "string" || typeof url !== "string") { + return undefined; + } + + return stableB2ConfigJson({ name, targetType, url }); +} + +function notificationRuleLabel(rule: unknown): string { + if (!isRecord(rule) || !isRecord(rule.targetConfiguration)) { + return "notification rule"; + } + + const name = typeof rule.name === "string" ? rule.name : "[unnamed]"; + const url = + typeof rule.targetConfiguration.url === "string" + ? rule.targetConfiguration.url + : "[unknown target]"; + return `notification rule "${name}" targeting "${url}"`; +} + +function findHeaderKey( + headers: Readonly>, + headerName: string, +): string | undefined { return Object.keys(headers).find( (candidate) => candidate.toLowerCase() === headerName.toLowerCase(), ); @@ -79,7 +194,7 @@ function maskNotificationRules(value: unknown): unknown { continue; } - for (const secretKey of ["hmacSha256SigningSecret", "hmacSha256"]) { + for (const secretKey of NOTIFICATION_SECRET_KEYS) { if (typeof rule.targetConfiguration[secretKey] === "string") { rule.targetConfiguration[secretKey] = B2_CONFIG_MASKED_SECRET; } @@ -91,7 +206,7 @@ function maskNotificationRules(value: unknown): unknown { } for (const [headerName, headerValue] of Object.entries(customHeaders)) { - if (isAuthorizationHeader(headerName) && typeof headerValue === "string") { + if (typeof headerValue === "string") { customHeaders[headerName] = B2_CONFIG_MASKED_SECRET; } } @@ -104,50 +219,167 @@ export function maskB2ConfigForRead(kind: B2ConfigKind, value: unknown): unknown return kind === "notifications" ? maskNotificationRules(value) : cloneB2ConfigJson(value); } -function mergeNotificationSecrets(edited: unknown, original: unknown): unknown { +function hasNotificationMask(rule: unknown): boolean { + if (!isRecord(rule) || !isRecord(rule.targetConfiguration)) { + return false; + } + const { targetConfiguration } = rule; + + if ( + NOTIFICATION_SECRET_KEYS.some((key) => targetConfiguration[key] === B2_CONFIG_MASKED_SECRET) + ) { + return true; + } + + const customHeaders = targetConfiguration.customHeaders; + return ( + isRecord(customHeaders) && + Object.values(customHeaders).some((headerValue) => headerValue === B2_CONFIG_MASKED_SECRET) + ); +} + +function notificationSecretsFromRule(rule: unknown): NotificationRuleSecretSnapshot | undefined { + if (!isRecord(rule) || !isRecord(rule.targetConfiguration)) { + return undefined; + } + + const secrets: Record = {}; + for (const secretKey of NOTIFICATION_SECRET_KEYS) { + const secretValue = rule.targetConfiguration[secretKey]; + if (typeof secretValue === "string") { + secrets[secretKey] = secretValue; + } + } + + const customHeaders: Record = {}; + const originalHeaders = rule.targetConfiguration.customHeaders; + if (isRecord(originalHeaders)) { + for (const [headerName, headerValue] of Object.entries(originalHeaders)) { + if (typeof headerValue === "string") { + customHeaders[headerName] = headerValue; + } + } + } + + if (Object.keys(secrets).length === 0 && Object.keys(customHeaders).length === 0) { + return undefined; + } + + return { + label: notificationRuleLabel(rule), + secrets, + customHeaders, + }; +} + +export function createB2ConfigSecretSnapshot( + kind: B2ConfigKind, + value: unknown, +): B2ConfigSecretSnapshot | undefined { + if (kind !== "notifications" || !Array.isArray(value)) { + return undefined; + } + + const identityCounts = new Map(); + const rulesByIdentity = new Map(); + + for (const rule of value) { + const identity = notificationRuleIdentity(rule); + if (!identity) { + continue; + } + identityCounts.set(identity, (identityCounts.get(identity) ?? 0) + 1); + + const secretSnapshot = notificationSecretsFromRule(rule); + if (secretSnapshot) { + rulesByIdentity.set(identity, secretSnapshot); + } + } + + const duplicateIdentities = [...identityCounts.entries()] + .filter(([, count]) => count > 1) + .map(([identity]) => identity); + + return { + rulesByIdentity: Object.fromEntries(rulesByIdentity), + duplicateIdentities, + }; +} + +function mergeNotificationSecrets( + edited: unknown, + secretSnapshot: B2ConfigSecretSnapshot | undefined, +): unknown { const merged = cloneB2ConfigJson(edited); - if (!Array.isArray(merged) || !Array.isArray(original)) { + if (!Array.isArray(merged)) { return merged; } + const usedMaskedIdentities = new Set(); for (const [index, rule] of merged.entries()) { - const originalRule = original[index]; - if ( - !isRecord(rule) || - !isRecord(originalRule) || - !isRecord(rule.targetConfiguration) || - !isRecord(originalRule.targetConfiguration) - ) { + if (!hasNotificationMask(rule)) { continue; } - for (const secretKey of ["hmacSha256SigningSecret", "hmacSha256"]) { + const identity = notificationRuleIdentity(rule); + if (!identity) { + throw new Error( + `Masked secrets in notifications[${index}] cannot be restored because the rule identity is incomplete.`, + ); + } + if (secretSnapshot?.duplicateIdentities.includes(identity)) { + throw new Error( + `Masked secrets for ${notificationRuleLabel(rule)} cannot be restored because the original rules are ambiguous.`, + ); + } + if (usedMaskedIdentities.has(identity)) { + throw new Error( + `Masked secrets for ${notificationRuleLabel(rule)} cannot be restored because the edited rules are ambiguous.`, + ); + } + + const originalSecrets = secretSnapshot?.rulesByIdentity[identity]; + if (!originalSecrets) { + throw new Error( + `Masked secrets for ${notificationRuleLabel(rule)} cannot be restored. Replace masks with real values or reload the document.`, + ); + } + usedMaskedIdentities.add(identity); + + if (!isRecord(rule) || !isRecord(rule.targetConfiguration)) { + continue; + } + + for (const secretKey of NOTIFICATION_SECRET_KEYS) { if (rule.targetConfiguration[secretKey] !== B2_CONFIG_MASKED_SECRET) { continue; } - const originalSecret = originalRule.targetConfiguration[secretKey]; - if (typeof originalSecret === "string") { - rule.targetConfiguration[secretKey] = originalSecret; + + const originalSecret = originalSecrets.secrets[secretKey]; + if (typeof originalSecret !== "string") { + throw new Error( + `Masked ${secretKey} for ${originalSecrets.label} cannot be restored because no original value exists.`, + ); } + rule.targetConfiguration[secretKey] = originalSecret; } const editedHeaders = rule.targetConfiguration.customHeaders; - const originalHeaders = originalRule.targetConfiguration.customHeaders; - if (!isRecord(editedHeaders) || !isRecord(originalHeaders)) { + if (!isRecord(editedHeaders)) { continue; } for (const [headerName, headerValue] of Object.entries(editedHeaders)) { - if (!isAuthorizationHeader(headerName) || headerValue !== B2_CONFIG_MASKED_SECRET) { + if (headerValue !== B2_CONFIG_MASKED_SECRET) { continue; } - const originalHeaderName = findHeaderKey(originalHeaders, headerName); - if ( - originalHeaderName !== undefined && - typeof originalHeaders[originalHeaderName] === "string" - ) { - editedHeaders[headerName] = originalHeaders[originalHeaderName]; + const originalHeaderName = findHeaderKey(originalSecrets.customHeaders, headerName); + if (originalHeaderName === undefined) { + throw new Error( + `Masked custom header "${headerName}" for ${originalSecrets.label} cannot be restored because no original value exists.`, + ); } + editedHeaders[headerName] = originalSecrets.customHeaders[originalHeaderName]; } } @@ -157,64 +389,184 @@ function mergeNotificationSecrets(edited: unknown, original: unknown): unknown { export function mergeMaskedB2Config( kind: B2ConfigKind, edited: unknown, - original: unknown, + secretSnapshot?: B2ConfigSecretSnapshot, ): unknown { return kind === "notifications" - ? mergeNotificationSecrets(edited, original) + ? mergeNotificationSecrets(edited, secretSnapshot) : cloneB2ConfigJson(edited); } -function validateArrayConfig(kind: B2ConfigKind, value: unknown): string | undefined { - if (Array.isArray(value)) { - return undefined; +function validateLifecycleRule(rule: unknown, index: number): string | undefined { + const path = `lifecycle[${index}]`; + if (!isRecord(rule)) { + return `${path} must be an object.`; } - return `${b2ConfigFileName(kind)} must contain a JSON array.`; + + return ( + validateNoUnexpectedProperties( + rule, + ["fileNamePrefix", "daysFromUploadingToHiding", "daysFromHidingToDeleting"], + path, + ) ?? + (typeof rule.fileNamePrefix === "string" + ? undefined + : `${path}.fileNamePrefix must be a string.`) ?? + (rule.daysFromUploadingToHiding === null || isIntegerAtLeast(rule.daysFromUploadingToHiding, 1) + ? undefined + : `${path}.daysFromUploadingToHiding must be an integer of at least 1 or null.`) ?? + (rule.daysFromHidingToDeleting === null || isIntegerAtLeast(rule.daysFromHidingToDeleting, 1) + ? undefined + : `${path}.daysFromHidingToDeleting must be an integer of at least 1 or null.`) + ); } -function validateBucketInfo(value: unknown): string | undefined { - if (!isRecord(value)) { - return "bucketInfo.json must contain a JSON object."; +function validateCorsRule(rule: unknown, index: number): string | undefined { + const path = `cors[${index}]`; + if (!isRecord(rule)) { + return `${path} must be an object.`; } - for (const [key, item] of Object.entries(value)) { - if (typeof item !== "string") { - return `bucketInfo.${key} must be a string.`; - } + const operationError = validateStringArray(rule.allowedOperations, `${path}.allowedOperations`); + if (operationError) { + return operationError; } - return undefined; + const invalidOperation = (rule.allowedOperations as unknown[]).find( + (operation) => typeof operation === "string" && !CORS_OPERATIONS.has(operation), + ); + + return ( + validateNoUnexpectedProperties( + rule, + [ + "corsRuleName", + "allowedOrigins", + "allowedOperations", + "allowedHeaders", + "exposeHeaders", + "maxAgeSeconds", + ], + path, + ) ?? + (typeof rule.corsRuleName === "string" + ? undefined + : `${path}.corsRuleName must be a string.`) ?? + validateStringArray(rule.allowedOrigins, `${path}.allowedOrigins`) ?? + (invalidOperation === undefined + ? undefined + : `${path}.allowedOperations contains unsupported operation "${String(invalidOperation)}".`) ?? + validateNullableStringArray(rule.allowedHeaders, `${path}.allowedHeaders`) ?? + validateNullableStringArray(rule.exposeHeaders, `${path}.exposeHeaders`) ?? + (isIntegerAtLeast(rule.maxAgeSeconds, 0) + ? undefined + : `${path}.maxAgeSeconds must be a non-negative integer.`) + ); } -function validateNotificationHeaders(value: unknown): string | undefined { - if (!Array.isArray(value)) { - return undefined; +function validateNotificationRule(rule: unknown, index: number): string | undefined { + const path = `notifications[${index}]`; + if (!isRecord(rule)) { + return `${path} must be an object.`; } - for (const [index, rule] of value.entries()) { - if (!isRecord(rule)) { - return `notifications[${index}] must be an object.`; - } - const targetConfiguration = rule.targetConfiguration; - if (targetConfiguration === undefined) { - continue; - } - if (!isRecord(targetConfiguration)) { - return `notifications[${index}].targetConfiguration must be an object.`; - } - const customHeaders = targetConfiguration.customHeaders; - if (customHeaders === undefined) { - continue; + const eventTypeError = validateStringArray(rule.eventTypes, `${path}.eventTypes`); + if (eventTypeError) { + return eventTypeError; + } + + const invalidEventType = (rule.eventTypes as unknown[]).find( + (eventType) => typeof eventType === "string" && !EVENT_TYPES.has(eventType), + ); + const targetConfiguration = rule.targetConfiguration; + if (!isRecord(targetConfiguration)) { + return `${path}.targetConfiguration must be an object.`; + } + + const url = targetConfiguration.url; + let urlError: string | undefined; + if (typeof url !== "string") { + urlError = `${path}.targetConfiguration.url must be a string.`; + } else { + try { + new URL(url); + } catch { + urlError = `${path}.targetConfiguration.url must be a valid URL.`; } + } + + const customHeaders = targetConfiguration.customHeaders; + if (customHeaders !== undefined) { if (!isRecord(customHeaders)) { - return `notifications[${index}].targetConfiguration.customHeaders must be an object.`; + return `${path}.targetConfiguration.customHeaders must be an object.`; } for (const [headerName, headerValue] of Object.entries(customHeaders)) { if (typeof headerValue !== "string") { - return `notifications[${index}].targetConfiguration.customHeaders.${headerName} must be a string.`; + return `${path}.targetConfiguration.customHeaders.${headerName} must be a string.`; } } } + for (const secretKey of NOTIFICATION_SECRET_KEYS) { + if ( + targetConfiguration[secretKey] !== undefined && + typeof targetConfiguration[secretKey] !== "string" + ) { + return `${path}.targetConfiguration.${secretKey} must be a string.`; + } + } + + return ( + (typeof rule.name === "string" ? undefined : `${path}.name must be a string.`) ?? + (invalidEventType === undefined + ? undefined + : `${path}.eventTypes contains unsupported event type "${String(invalidEventType)}".`) ?? + (typeof rule.isEnabled === "boolean" ? undefined : `${path}.isEnabled must be a boolean.`) ?? + (typeof rule.isSuspended === "boolean" + ? undefined + : `${path}.isSuspended must be a boolean.`) ?? + (typeof rule.objectNamePrefix === "string" + ? undefined + : `${path}.objectNamePrefix must be a string.`) ?? + (typeof rule.suspensionReason === "string" + ? undefined + : `${path}.suspensionReason must be a string.`) ?? + (typeof targetConfiguration.targetType === "string" + ? undefined + : `${path}.targetConfiguration.targetType must be a string.`) ?? + urlError + ); +} + +function validateArrayConfig( + kind: B2ConfigKind, + value: unknown, + validateItem: (item: unknown, index: number) => string | undefined, +): string | undefined { + if (!Array.isArray(value)) { + return `${b2ConfigFileName(kind)} must contain a JSON array.`; + } + + for (const [index, item] of value.entries()) { + const error = validateItem(item, index); + if (error) { + return error; + } + } + + return undefined; +} + +function validateBucketInfo(value: unknown): string | undefined { + if (!isRecord(value)) { + return "bucketInfo.json must contain a JSON object."; + } + + for (const [key, item] of Object.entries(value)) { + if (typeof item !== "string") { + return `bucketInfo.${key} must be a string.`; + } + } + return undefined; } @@ -223,10 +575,11 @@ export function validateB2ConfigJson(kind: B2ConfigKind, value: unknown): string case "bucketInfo": return validateBucketInfo(value); case "cors": + return validateArrayConfig(kind, value, validateCorsRule); case "lifecycle": - return validateArrayConfig(kind, value); + return validateArrayConfig(kind, value, validateLifecycleRule); case "notifications": - return validateArrayConfig(kind, value) ?? validateNotificationHeaders(value); + return validateArrayConfig(kind, value, validateNotificationRule); } } @@ -248,3 +601,7 @@ function normalizeForStableJson(value: unknown): unknown { export function stableB2ConfigJson(value: unknown): string { return JSON.stringify(normalizeForStableJson(value)); } + +export function fingerprintB2ConfigJson(value: unknown): string { + return createHash("sha256").update(stableB2ConfigJson(value)).digest("hex"); +} diff --git a/src/test/unit/b2ConfigJson.test.ts b/src/test/unit/b2ConfigJson.test.ts index 67fc0709..149be480 100644 --- a/src/test/unit/b2ConfigJson.test.ts +++ b/src/test/unit/b2ConfigJson.test.ts @@ -5,9 +5,17 @@ */ import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; import test from "node:test"; import { + B2_CONFIG_KINDS, B2_CONFIG_MASKED_SECRET, + B2_CONFIG_SCHEME, + b2ConfigFileName, + b2ConfigSchemaPath, + createB2ConfigSecretSnapshot, + fingerprintB2ConfigJson, maskB2ConfigForRead, mergeMaskedB2Config, parseB2ConfigPath, @@ -15,6 +23,52 @@ import { validateB2ConfigJson, } from "../../providers/b2ConfigJson"; +interface TestNotificationRule { + name: string; + eventTypes: string[]; + isEnabled: boolean; + isSuspended: boolean; + objectNamePrefix: string; + suspensionReason: string; + targetConfiguration: { + targetType: string; + url: string; + hmacSha256SigningSecret?: string; + hmacSha256?: string; + customHeaders?: Record; + }; +} + +function notificationRule( + name: string, + url: string, + options: { + readonly signingSecret?: string; + readonly legacySecret?: string; + readonly headers?: Record; + } = {}, +): TestNotificationRule { + return { + name, + eventTypes: ["b2:ObjectCreated:*"], + isEnabled: true, + isSuspended: false, + objectNamePrefix: "", + suspensionReason: "", + targetConfiguration: { + targetType: "url", + url, + ...(options.signingSecret ? { hmacSha256SigningSecret: options.signingSecret } : {}), + ...(options.legacySecret ? { hmacSha256: options.legacySecret } : {}), + ...(options.headers ? { customHeaders: options.headers } : {}), + }, + }; +} + +function maskedNotifications(rules: readonly TestNotificationRule[]): TestNotificationRule[] { + return maskB2ConfigForRead("notifications", rules) as TestNotificationRule[]; +} + test("parses b2-config bucket config document paths", () => { assert.deepEqual(parseB2ConfigPath("/my-bucket/lifecycle.json"), { bucketName: "my-bucket", @@ -38,93 +92,330 @@ test("parses b2-config bucket config document paths", () => { assert.equal(parseB2ConfigPath("/too/many/lifecycle.json"), undefined); }); -test("masks notification signing secrets and authorization headers on read", () => { +test("masks notification signing secrets and all custom headers on read", () => { const original = [ - { - name: "webhook", - targetConfiguration: { - targetType: "url", - url: "https://example.com/b2", - hmacSha256SigningSecret: "signing-secret", - hmacSha256: "legacy-secret", - customHeaders: { - Authorization: "Bearer secret", - "X-Trace": "keep-visible", - }, + notificationRule("webhook", "https://example.com/b2", { + signingSecret: "signing-secret", + legacySecret: "legacy-secret", + headers: { + Authorization: "Bearer secret", + "X-Api-Key": "api-key", + "X-Webhook-Secret": "webhook-secret", + "X-Trace": "trace-token", }, - }, + }), ]; - const masked = maskB2ConfigForRead("notifications", original) as typeof original; + const masked = maskedNotifications(original); assert.equal(masked[0].targetConfiguration.hmacSha256SigningSecret, B2_CONFIG_MASKED_SECRET); assert.equal(masked[0].targetConfiguration.hmacSha256, B2_CONFIG_MASKED_SECRET); - assert.equal(masked[0].targetConfiguration.customHeaders.Authorization, B2_CONFIG_MASKED_SECRET); - assert.equal(masked[0].targetConfiguration.customHeaders["X-Trace"], "keep-visible"); + assert.deepEqual(masked[0].targetConfiguration.customHeaders, { + Authorization: B2_CONFIG_MASKED_SECRET, + "X-Api-Key": B2_CONFIG_MASKED_SECRET, + "X-Webhook-Secret": B2_CONFIG_MASKED_SECRET, + "X-Trace": B2_CONFIG_MASKED_SECRET, + }); assert.equal(original[0].targetConfiguration.hmacSha256SigningSecret, "signing-secret"); }); -test("merges unchanged notification masks back to the original secret values", () => { +test("merges unchanged notification masks back to matching original rule secrets", () => { const original = [ - { - targetConfiguration: { - hmacSha256SigningSecret: "signing-secret", - customHeaders: { - Authorization: "Bearer secret", - }, + notificationRule("webhook", "https://example.com/b2", { + signingSecret: "signing-secret", + headers: { + Authorization: "Bearer secret", + "X-Api-Key": "api-key", }, - }, + }), ]; - const edited = maskB2ConfigForRead("notifications", original) as typeof original; + const edited = maskedNotifications(original); - const merged = mergeMaskedB2Config("notifications", edited, original); + const merged = mergeMaskedB2Config( + "notifications", + edited, + createB2ConfigSecretSnapshot("notifications", original), + ); assert.deepEqual(merged, original); }); +test("restores reordered notification masks by stable rule identity", () => { + const original = [ + notificationRule("alpha", "https://alpha.example.com/b2", { + signingSecret: "alpha-secret", + headers: { "X-Api-Key": "alpha-api-key" }, + }), + notificationRule("beta", "https://beta.example.com/b2", { + signingSecret: "beta-secret", + headers: { "X-Api-Key": "beta-api-key" }, + }), + ]; + const edited = maskedNotifications(original).reverse(); + + const merged = mergeMaskedB2Config( + "notifications", + edited, + createB2ConfigSecretSnapshot("notifications", original), + ) as TestNotificationRule[]; + + assert.equal(merged[0].name, "beta"); + assert.equal(merged[0].targetConfiguration.hmacSha256SigningSecret, "beta-secret"); + assert.equal(merged[0].targetConfiguration.customHeaders?.["X-Api-Key"], "beta-api-key"); + assert.equal(merged[1].name, "alpha"); + assert.equal(merged[1].targetConfiguration.hmacSha256SigningSecret, "alpha-secret"); + assert.equal(merged[1].targetConfiguration.customHeaders?.["X-Api-Key"], "alpha-api-key"); +}); + +test("restores remaining notification masks after a rule deletion by identity", () => { + const original = [ + notificationRule("deleted", "https://deleted.example.com/b2", { + signingSecret: "deleted-secret", + }), + notificationRule("remaining", "https://remaining.example.com/b2", { + signingSecret: "remaining-secret", + }), + ]; + const edited = maskedNotifications(original).slice(1); + + const merged = mergeMaskedB2Config( + "notifications", + edited, + createB2ConfigSecretSnapshot("notifications", original), + ) as TestNotificationRule[]; + + assert.equal(merged.length, 1); + assert.equal(merged[0].name, "remaining"); + assert.equal(merged[0].targetConfiguration.hmacSha256SigningSecret, "remaining-secret"); +}); + +test("rejects masked secrets on inserted notification rules", () => { + const original = [ + notificationRule("alpha", "https://alpha.example.com/b2", { + signingSecret: "alpha-secret", + }), + ]; + const edited = [ + notificationRule("inserted", "https://inserted.example.com/b2", { + signingSecret: B2_CONFIG_MASKED_SECRET, + headers: { "X-Api-Key": B2_CONFIG_MASKED_SECRET }, + }), + ]; + + assert.throws( + () => + mergeMaskedB2Config( + "notifications", + edited, + createB2ConfigSecretSnapshot("notifications", original), + ), + /cannot be restored/u, + ); +}); + +test("rejects masked secrets when notification rule identity is renamed", () => { + const original = [ + notificationRule("alpha", "https://alpha.example.com/b2", { + signingSecret: "alpha-secret", + }), + ]; + const edited = maskedNotifications(original); + edited[0].name = "renamed"; + + assert.throws( + () => + mergeMaskedB2Config( + "notifications", + edited, + createB2ConfigSecretSnapshot("notifications", original), + ), + /cannot be restored/u, + ); +}); + +test("rejects masked secrets when notification target URL changes", () => { + const original = [ + notificationRule("alpha", "https://alpha.example.com/b2", { + signingSecret: "alpha-secret", + headers: { "X-Api-Key": "alpha-api-key" }, + }), + ]; + const edited = maskedNotifications(original); + edited[0].targetConfiguration.url = "https://attacker.example.com/b2"; + + assert.throws( + () => + mergeMaskedB2Config( + "notifications", + edited, + createB2ConfigSecretSnapshot("notifications", original), + ), + /cannot be restored/u, + ); +}); + +test("rejects masked secrets when notification target type changes", () => { + const original = [ + notificationRule("alpha", "https://alpha.example.com/b2", { + headers: { "X-Api-Key": "alpha-api-key" }, + }), + ]; + const edited = maskedNotifications(original); + edited[0].targetConfiguration.targetType = "attacker"; + + assert.throws( + () => + mergeMaskedB2Config( + "notifications", + edited, + createB2ConfigSecretSnapshot("notifications", original), + ), + /cannot be restored/u, + ); +}); + +test("rejects masked secrets for ambiguous notification rule identities", () => { + const original = [ + notificationRule("same", "https://same.example.com/b2", { + signingSecret: "first-secret", + }), + notificationRule("same", "https://same.example.com/b2", { + signingSecret: "second-secret", + }), + ]; + const edited = [maskedNotifications(original)[0]]; + + assert.throws( + () => + mergeMaskedB2Config( + "notifications", + edited, + createB2ConfigSecretSnapshot("notifications", original), + ), + /ambiguous/u, + ); +}); + test("changed notification secrets replace originals while unchanged masks are preserved", () => { const original = [ - { - targetConfiguration: { - hmacSha256SigningSecret: "old-secret", - customHeaders: { - Authorization: "Bearer old", - }, + notificationRule("webhook", "https://example.com/b2", { + signingSecret: "old-secret", + headers: { + Authorization: "Bearer old", }, - }, + }), ]; - const edited = maskB2ConfigForRead("notifications", original) as typeof original; + const edited = maskedNotifications(original); edited[0].targetConfiguration.hmacSha256SigningSecret = "new-secret"; - const merged = mergeMaskedB2Config("notifications", edited, original) as typeof original; + const merged = mergeMaskedB2Config( + "notifications", + edited, + createB2ConfigSecretSnapshot("notifications", original), + ) as TestNotificationRule[]; assert.equal(merged[0].targetConfiguration.hmacSha256SigningSecret, "new-secret"); - assert.equal(merged[0].targetConfiguration.customHeaders.Authorization, "Bearer old"); + assert.equal(merged[0].targetConfiguration.customHeaders?.Authorization, "Bearer old"); }); -test("validates top-level config shapes before save", () => { - assert.equal(validateB2ConfigJson("lifecycle", []), undefined); - assert.equal(validateB2ConfigJson("cors", []), undefined); - assert.equal(validateB2ConfigJson("notifications", []), undefined); +test("validates config shapes before save", () => { + assert.equal( + validateB2ConfigJson("lifecycle", [ + { + fileNamePrefix: "logs/", + daysFromUploadingToHiding: 30, + daysFromHidingToDeleting: null, + }, + ]), + undefined, + ); + assert.equal( + validateB2ConfigJson("cors", [ + { + corsRuleName: "browser", + allowedOrigins: ["https://example.com"], + allowedOperations: ["b2_download_file_by_name"], + allowedHeaders: null, + exposeHeaders: null, + maxAgeSeconds: 300, + }, + ]), + undefined, + ); + assert.equal( + validateB2ConfigJson("notifications", [notificationRule("webhook", "https://example.com/b2")]), + undefined, + ); assert.equal(validateB2ConfigJson("bucketInfo", { team: "platform" }), undefined); assert.match(validateB2ConfigJson("lifecycle", {}) ?? "", /array/u); + assert.match(validateB2ConfigJson("lifecycle", [{}]) ?? "", /fileNamePrefix/u); + assert.match( + validateB2ConfigJson("cors", [ + { + corsRuleName: "browser", + allowedOrigins: ["https://example.com"], + allowedOperations: ["unsupported"], + allowedHeaders: null, + exposeHeaders: null, + maxAgeSeconds: 300, + }, + ]) ?? "", + /unsupported operation/u, + ); assert.match(validateB2ConfigJson("bucketInfo", []) ?? "", /object/u); assert.match(validateB2ConfigJson("bucketInfo", { team: 1 }) ?? "", /string/u); + assert.match( + validateB2ConfigJson("notifications", [{ ...notificationRule("webhook", "not a url") }]) ?? "", + /valid URL/u, + ); assert.match( validateB2ConfigJson("notifications", [ - { targetConfiguration: { customHeaders: { Authorization: 1 } } }, + { + ...notificationRule("webhook", "https://example.com/b2", { + headers: { Authorization: "ok" }, + }), + targetConfiguration: { + ...notificationRule("webhook", "https://example.com/b2").targetConfiguration, + customHeaders: { Authorization: 1 }, + }, + }, ]) ?? "", /must be a string/u, ); }); -test("stable config JSON ignores object key insertion order", () => { - assert.equal( - stableB2ConfigJson({ b: 1, a: { d: 4, c: 3 } }), - stableB2ConfigJson({ - a: { c: 3, d: 4 }, - b: 1, - }), - ); +test("stable config JSON and fingerprint ignore object key insertion order", () => { + const left = { b: 1, a: { d: 4, c: 3 } }; + const right = { + a: { c: 3, d: 4 }, + b: 1, + }; + + assert.equal(stableB2ConfigJson(left), stableB2ConfigJson(right)); + assert.equal(fingerprintB2ConfigJson(left), fingerprintB2ConfigJson(right)); +}); + +test("config kind metadata matches package jsonValidation and schema files", () => { + const repoRoot = process.cwd(); + const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")) as { + contributes: { + jsonValidation: Array<{ + fileMatch: string[]; + url: string; + }>; + }; + }; + + for (const kind of B2_CONFIG_KINDS) { + const schemaPath = b2ConfigSchemaPath(kind); + const schemaFile = path.join(repoRoot, schemaPath.replace(/^\.\//u, "")); + assert.equal(fs.existsSync(schemaFile), true, `${schemaPath} should exist`); + + const validation = packageJson.contributes.jsonValidation.find( + (entry) => entry.url === schemaPath, + ); + assert.ok(validation, `${kind} should have a jsonValidation contribution`); + assert.deepEqual(validation.fileMatch, [`${B2_CONFIG_SCHEME}:/**/${b2ConfigFileName(kind)}`]); + } }); From 60fc9bcf824504a9f624115705095277d2a789f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Fri, 3 Jul 2026 09:16:08 -0500 Subject: [PATCH 3/8] fix: address config provider reviews --- scripts/release-contract.js | 4 +- src/providers/b2ConfigFileSystemProvider.ts | 9 +- .../suite/b2ConfigFileSystemProvider.test.ts | 200 ++++++++++++++++++ 3 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 src/test/suite/b2ConfigFileSystemProvider.test.ts diff --git a/scripts/release-contract.js b/scripts/release-contract.js index 9c0ecc1b..519d81b3 100644 --- a/scripts/release-contract.js +++ b/scripts/release-contract.js @@ -21,7 +21,7 @@ const manifestContract = { type: "git", url: "https://github.com/backblaze-labs/b2-vscode.git", }, - activationEvents: [], + activationEvents: ["onFileSystem:b2-config"], forbiddenTopLevelFields: ["extensionDependencies", "extensionPack"], requiredPackageEntries: [ "extension/resources/b2-icon.png", @@ -59,7 +59,7 @@ const manifestContract = { "b2_deleteFile", "b2_presignUrl", ], - contributesSha256: "40a5f09a30986dfcb974032113ec6afaa2a694fa9fd12421b222955dac64eda7", + contributesSha256: "ded59c90229a7abbf10894fb367ca48e6d4040a8069848ff076e7cf88cfd5b2c", }; function stableStringify(value) { diff --git a/src/providers/b2ConfigFileSystemProvider.ts b/src/providers/b2ConfigFileSystemProvider.ts index b4fb7bf8..2350530f 100644 --- a/src/providers/b2ConfigFileSystemProvider.ts +++ b/src/providers/b2ConfigFileSystemProvider.ts @@ -450,12 +450,13 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs revision: number, ): Promise<{ readonly config: unknown; readonly revision: number }> { try { - await this.withRemoteTimeout( + const response = await this.withRemoteTimeout( location, "save notification rules", () => bucket.setNotificationRules(config as EventNotificationRule[]), { observeLateOutcome: true }, ); + return { config: response.eventNotificationRules, revision }; } catch (error) { const reconciled = await this.reconcileNotificationSave(bucket, location, config, error); if (reconciled) { @@ -463,8 +464,6 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs } throw error; } - - return { config, revision }; } private async reconcileNotificationSave( @@ -507,10 +506,6 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs location: B2ConfigLocation, snapshot: B2ConfigCacheEntry, ): Promise { - if (bucket.info.revision !== snapshot.revision) { - throw new B2ConfigConflictError(this.conflictMessage(snapshot)); - } - const current = await this.withRemoteTimeout(location, "check notification rules", () => bucket.getNotificationRules(), ); diff --git a/src/test/suite/b2ConfigFileSystemProvider.test.ts b/src/test/suite/b2ConfigFileSystemProvider.test.ts new file mode 100644 index 00000000..558f7403 --- /dev/null +++ b/src/test/suite/b2ConfigFileSystemProvider.test.ts @@ -0,0 +1,200 @@ +/** + * Tests for the b2-config virtual file-system provider. + * + * @module test/suite/b2ConfigFileSystemProvider + */ + +import * as assert from "assert"; +import type { EventNotificationRule } from "@backblaze-labs/b2-sdk"; +import { + B2ConfigFileSystemProvider, + buildB2ConfigUri, + type B2ConfigBucket, + type B2ConfigClient, +} from "../../providers/b2ConfigFileSystemProvider"; +import { + B2_CONFIG_MASKED_SECRET, + maskB2ConfigForRead, + prettyB2ConfigJson, +} from "../../providers/b2ConfigJson"; + +function notificationRule( + name: string, + url: string, + options: { + readonly signingSecret?: string; + readonly headers?: Record; + readonly objectNamePrefix?: string; + readonly isSuspended?: boolean; + } = {}, +): EventNotificationRule { + const rule: EventNotificationRule = { + name, + eventTypes: ["b2:ObjectCreated:*"], + isEnabled: true, + isSuspended: options.isSuspended ?? false, + objectNamePrefix: options.objectNamePrefix ?? "", + suspensionReason: "", + targetConfiguration: { + targetType: "url", + url, + ...(options.signingSecret ? { hmacSha256SigningSecret: options.signingSecret } : {}), + ...(options.headers ? { customHeaders: options.headers } : {}), + }, + }; + return rule; +} + +function encodeConfig(value: unknown): Uint8Array { + return Buffer.from(prettyB2ConfigJson(value), "utf8"); +} + +function makeNotificationProvider( + initialRules: EventNotificationRule[], + options: { + readonly normalizeSetResult?: (rules: EventNotificationRule[]) => EventNotificationRule[]; + } = {}, +): { + readonly bucket: B2ConfigBucket; + readonly provider: B2ConfigFileSystemProvider; + readonly savedRules: EventNotificationRule[][]; + liveRules: EventNotificationRule[]; +} { + const state = { + liveRules: initialRules, + }; + const savedRules: EventNotificationRule[][] = []; + const bucket = { + name: "bucket", + info: { + bucketName: "bucket", + bucketInfo: {}, + corsRules: [], + lifecycleRules: [], + revision: 1, + }, + async update() { + throw new Error("update should not be called for notification tests"); + }, + async getNotificationRules() { + return { eventNotificationRules: state.liveRules }; + }, + async setNotificationRules(rules: EventNotificationRule[]) { + savedRules.push(rules); + state.liveRules = options.normalizeSetResult?.(rules) ?? rules; + return { eventNotificationRules: state.liveRules }; + }, + } as unknown as B2ConfigBucket; + const client = { + async getBucket(bucketName: string) { + return bucketName === "bucket" ? bucket : null; + }, + } satisfies B2ConfigClient; + + return { + bucket, + provider: new B2ConfigFileSystemProvider(() => client), + savedRules, + get liveRules() { + return state.liveRules; + }, + set liveRules(value: EventNotificationRule[]) { + state.liveRules = value; + }, + }; +} + +suite("B2 config file-system provider", () => { + test("masks notification secrets on read and restores them on save", async () => { + const original = [ + notificationRule("webhook", "https://example.com/b2", { + signingSecret: "signing-secret", + headers: { + Authorization: "Bearer secret", + "X-Api-Key": "api-key", + }, + }), + ]; + const { provider, savedRules } = makeNotificationProvider(original); + const uri = buildB2ConfigUri("bucket", "notifications"); + + const readConfig = JSON.parse(Buffer.from(await provider.readFile(uri)).toString("utf8")) as + | EventNotificationRule[] + | undefined; + + assert.strictEqual( + readConfig?.[0]?.targetConfiguration.hmacSha256SigningSecret, + B2_CONFIG_MASKED_SECRET, + ); + assert.deepStrictEqual(readConfig?.[0]?.targetConfiguration.customHeaders, { + Authorization: B2_CONFIG_MASKED_SECRET, + "X-Api-Key": B2_CONFIG_MASKED_SECRET, + }); + + assert.ok(readConfig); + (readConfig[0] as { objectNamePrefix: string }).objectNamePrefix = "logs/"; + await provider.writeFile(uri, encodeConfig(readConfig), { create: false, overwrite: true }); + + assert.strictEqual(savedRules.length, 1); + assert.strictEqual( + savedRules[0][0].targetConfiguration.hmacSha256SigningSecret, + "signing-secret", + ); + assert.deepStrictEqual(savedRules[0][0].targetConfiguration.customHeaders, { + Authorization: "Bearer secret", + "X-Api-Key": "api-key", + }); + }); + + test("uses server-returned notification rules for the next save snapshot", async () => { + const original = [ + notificationRule("webhook", "https://example.com/b2", { + signingSecret: "signing-secret", + }), + ]; + const fixture = makeNotificationProvider(original, { + normalizeSetResult: (rules) => rules.map((rule) => ({ ...rule, isSuspended: true })), + }); + const { provider, savedRules } = fixture; + const uri = buildB2ConfigUri("bucket", "notifications"); + + const readConfig = JSON.parse(Buffer.from(await provider.readFile(uri)).toString("utf8")) as + | EventNotificationRule[] + | undefined; + assert.ok(readConfig); + (readConfig[0] as { objectNamePrefix: string }).objectNamePrefix = "logs/"; + + await provider.writeFile(uri, encodeConfig(readConfig), { create: false, overwrite: true }); + + const nextEdit = maskB2ConfigForRead( + "notifications", + fixture.liveRules, + ) as EventNotificationRule[]; + (nextEdit[0] as { objectNamePrefix: string }).objectNamePrefix = "images/"; + + await provider.writeFile(uri, encodeConfig(nextEdit), { create: false, overwrite: true }); + + assert.strictEqual(savedRules.length, 2); + }); + + test("notification saves ignore unrelated bucket revision changes", async () => { + const original = [ + notificationRule("webhook", "https://example.com/b2", { + signingSecret: "signing-secret", + }), + ]; + const { bucket, provider, savedRules } = makeNotificationProvider(original); + const uri = buildB2ConfigUri("bucket", "notifications"); + + const readConfig = JSON.parse(Buffer.from(await provider.readFile(uri)).toString("utf8")) as + | EventNotificationRule[] + | undefined; + assert.ok(readConfig); + (readConfig[0] as { objectNamePrefix: string }).objectNamePrefix = "logs/"; + (bucket.info as { revision: number }).revision = 2; + + await provider.writeFile(uri, encodeConfig(readConfig), { create: false, overwrite: true }); + + assert.strictEqual(savedRules.length, 1); + }); +}); From 7ef6808bc23e461d6c0e601402b9552f1f33ca36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Fri, 3 Jul 2026 09:25:06 -0500 Subject: [PATCH 4/8] fix: align config JSON guards --- src/providers/b2ConfigFileSystemProvider.ts | 17 +++++++++-- src/providers/b2ConfigJson.ts | 31 +++++++++++++++++++-- src/test/unit/b2ConfigJson.test.ts | 29 +++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/providers/b2ConfigFileSystemProvider.ts b/src/providers/b2ConfigFileSystemProvider.ts index 2350530f..418ae77e 100644 --- a/src/providers/b2ConfigFileSystemProvider.ts +++ b/src/providers/b2ConfigFileSystemProvider.ts @@ -33,6 +33,7 @@ import { export const B2_CONFIG_REMOTE_TIMEOUT_MS = 60_000; export const B2_CONFIG_CACHE_TTL_MS = 15 * 60 * 1000; export const B2_CONFIG_CACHE_MAX_ENTRIES = 32; +export const B2_CONFIG_CACHE_MAX_BYTES = 4 * 1024 * 1024; type BucketConfigUpdate = { readonly bucketInfo?: Record; @@ -311,15 +312,27 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs } } - if (this.cache.size <= B2_CONFIG_CACHE_MAX_ENTRIES) { + const totalSize = [...this.cache.values()].reduce((sum, entry) => sum + entry.size, 0); + if (this.cache.size <= B2_CONFIG_CACHE_MAX_ENTRIES && totalSize <= B2_CONFIG_CACHE_MAX_BYTES) { return; } const entriesByAge = [...this.cache.entries()].sort( ([, left], [, right]) => left.lastAccessedAt - right.lastAccessedAt, ); - for (const [key] of entriesByAge.slice(0, this.cache.size - B2_CONFIG_CACHE_MAX_ENTRIES)) { + let remainingSize = totalSize; + for (const [key, entry] of entriesByAge) { + if ( + this.cache.size <= B2_CONFIG_CACHE_MAX_ENTRIES && + remainingSize <= B2_CONFIG_CACHE_MAX_BYTES + ) { + break; + } + if (this.cache.size <= 1) { + break; + } this.cache.delete(key); + remainingSize -= entry.size; } } diff --git a/src/providers/b2ConfigJson.ts b/src/providers/b2ConfigJson.ts index 615a9f17..cf6b2e15 100644 --- a/src/providers/b2ConfigJson.ts +++ b/src/providers/b2ConfigJson.ts @@ -108,7 +108,7 @@ export function b2ConfigSchemaPath(kind: B2ConfigKind): string { } export function prettyB2ConfigJson(value: unknown): string { - return `${JSON.stringify(value, null, 2)}\n`; + return `${JSON.stringify(value, null, 2) ?? "null"}\n`; } export function cloneB2ConfigJson(value: unknown): unknown { @@ -132,6 +132,17 @@ function validateStringArray(value: unknown, path: string): string | undefined { return invalidIndex === -1 ? undefined : `${path}[${invalidIndex}] must be a string.`; } +function validateUniqueStrings(value: readonly string[], path: string): string | undefined { + const seen = new Set(); + for (const item of value) { + if (seen.has(item)) { + return `${path} must not contain duplicate value "${item}".`; + } + seen.add(item); + } + return undefined; +} + function validateNullableStringArray(value: unknown, path: string): string | undefined { return value === null ? undefined : validateStringArray(value, path); } @@ -431,6 +442,14 @@ function validateCorsRule(rule: unknown, index: number): string | undefined { return operationError; } + const duplicateOperationError = validateUniqueStrings( + rule.allowedOperations as string[], + `${path}.allowedOperations`, + ); + if (duplicateOperationError) { + return duplicateOperationError; + } + const invalidOperation = (rule.allowedOperations as unknown[]).find( (operation) => typeof operation === "string" && !CORS_OPERATIONS.has(operation), ); @@ -474,6 +493,14 @@ function validateNotificationRule(rule: unknown, index: number): string | undefi return eventTypeError; } + const duplicateEventTypeError = validateUniqueStrings( + rule.eventTypes as string[], + `${path}.eventTypes`, + ); + if (duplicateEventTypeError) { + return duplicateEventTypeError; + } + const invalidEventType = (rule.eventTypes as unknown[]).find( (eventType) => typeof eventType === "string" && !EVENT_TYPES.has(eventType), ); @@ -599,7 +626,7 @@ function normalizeForStableJson(value: unknown): unknown { } export function stableB2ConfigJson(value: unknown): string { - return JSON.stringify(normalizeForStableJson(value)); + return JSON.stringify(normalizeForStableJson(value)) ?? "null"; } export function fingerprintB2ConfigJson(value: unknown): string { diff --git a/src/test/unit/b2ConfigJson.test.ts b/src/test/unit/b2ConfigJson.test.ts index 149be480..6d7f291c 100644 --- a/src/test/unit/b2ConfigJson.test.ts +++ b/src/test/unit/b2ConfigJson.test.ts @@ -19,6 +19,7 @@ import { maskB2ConfigForRead, mergeMaskedB2Config, parseB2ConfigPath, + prettyB2ConfigJson, stableB2ConfigJson, validateB2ConfigJson, } from "../../providers/b2ConfigJson"; @@ -363,12 +364,34 @@ test("validates config shapes before save", () => { ]) ?? "", /unsupported operation/u, ); + assert.match( + validateB2ConfigJson("cors", [ + { + corsRuleName: "browser", + allowedOrigins: ["https://example.com"], + allowedOperations: ["b2_download_file_by_name", "b2_download_file_by_name"], + allowedHeaders: null, + exposeHeaders: null, + maxAgeSeconds: 300, + }, + ]) ?? "", + /duplicate value/u, + ); assert.match(validateB2ConfigJson("bucketInfo", []) ?? "", /object/u); assert.match(validateB2ConfigJson("bucketInfo", { team: 1 }) ?? "", /string/u); assert.match( validateB2ConfigJson("notifications", [{ ...notificationRule("webhook", "not a url") }]) ?? "", /valid URL/u, ); + assert.match( + validateB2ConfigJson("notifications", [ + { + ...notificationRule("webhook", "https://example.com/b2"), + eventTypes: ["b2:ObjectCreated:*", "b2:ObjectCreated:*"], + }, + ]) ?? "", + /duplicate value/u, + ); assert.match( validateB2ConfigJson("notifications", [ { @@ -396,6 +419,12 @@ test("stable config JSON and fingerprint ignore object key insertion order", () assert.equal(fingerprintB2ConfigJson(left), fingerprintB2ConfigJson(right)); }); +test("JSON helpers keep undefined values representable as JSON", () => { + assert.equal(prettyB2ConfigJson(undefined), "null\n"); + assert.equal(stableB2ConfigJson(undefined), "null"); + assert.equal(fingerprintB2ConfigJson(undefined), fingerprintB2ConfigJson(null)); +}); + test("config kind metadata matches package jsonValidation and schema files", () => { const repoRoot = process.cwd(); const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")) as { From 4247ed9b8f16a1dc9852855ea7d5830299911cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Fri, 3 Jul 2026 09:33:42 -0500 Subject: [PATCH 5/8] fix: refine config provider snapshots --- src/providers/b2ConfigFileSystemProvider.ts | 33 ++++++++++++++++--- .../suite/b2ConfigFileSystemProvider.test.ts | 13 ++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/providers/b2ConfigFileSystemProvider.ts b/src/providers/b2ConfigFileSystemProvider.ts index 418ae77e..d9dd03fb 100644 --- a/src/providers/b2ConfigFileSystemProvider.ts +++ b/src/providers/b2ConfigFileSystemProvider.ts @@ -115,13 +115,14 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs }; } - this.parseUri(uri); + const location = this.parseUri(uri); const cacheEntry = this.getCacheEntry(this.cacheKey(uri)); + const defaultConfig = location.kind === "bucketInfo" ? {} : []; return { type: vscode.FileType.File, ctime: 0, mtime: cacheEntry?.mtime ?? Date.now(), - size: cacheEntry?.size ?? Buffer.byteLength(prettyB2ConfigJson([])), + size: cacheEntry?.size ?? Buffer.byteLength(prettyB2ConfigJson(defaultConfig)), }; } @@ -187,7 +188,22 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs await this.rejectWrite(`B2: ${validationError}`); } - const snapshot = cacheEntry ?? (await this.createWriteSnapshot(uri, location)); + let snapshot: B2ConfigCacheEntry; + try { + snapshot = cacheEntry ?? (await this.createWriteSnapshot(uri, location)); + } catch (error) { + if (error instanceof B2ConfigRemoteTimeoutError) { + await this.rejectWrite(`B2: ${error.message}`, error); + } + if (isBucketRevisionConflict(error)) { + await this.rejectWrite(this.conflictMessage(location), error); + } + await this.rejectWrite( + `B2: Failed to prepare ${b2ConfigFileName(location.kind)} for "${location.bucketName}". ${formatB2UserMessage(error)}`, + error, + ); + throw error; + } let mergedConfig: unknown; try { mergedConfig = mergeMaskedB2Config(location.kind, parsedConfig, snapshot.secretSnapshot); @@ -396,12 +412,19 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs uri: vscode.Uri, location: B2ConfigLocation, ): Promise { - const bytes = await this.readFile(uri); + const bucket = await this.resolveBucket(location, uri); + const { config, revision } = await this.readLiveConfig(bucket, location); + const bytes = Buffer.from( + prettyB2ConfigJson(maskB2ConfigForRead(location.kind, config)), + "utf8", + ); + this.rememberConfigSnapshot(uri, location, config, revision, bytes.byteLength); + const entry = this.cache.get(this.cacheKey(uri)); if (!entry) { throw new Error(`B2: Could not prepare ${b2ConfigFileName(location.kind)} for saving.`); } - return { ...entry, size: bytes.byteLength }; + return entry; } private async persistConfig( diff --git a/src/test/suite/b2ConfigFileSystemProvider.test.ts b/src/test/suite/b2ConfigFileSystemProvider.test.ts index 558f7403..9b467632 100644 --- a/src/test/suite/b2ConfigFileSystemProvider.test.ts +++ b/src/test/suite/b2ConfigFileSystemProvider.test.ts @@ -105,6 +105,19 @@ function makeNotificationProvider( } suite("B2 config file-system provider", () => { + test("uses kind-appropriate placeholder stat sizes before read", () => { + const { provider } = makeNotificationProvider([]); + + assert.strictEqual( + provider.stat(buildB2ConfigUri("bucket", "bucketInfo")).size, + Buffer.byteLength("{}\n"), + ); + assert.strictEqual( + provider.stat(buildB2ConfigUri("bucket", "notifications")).size, + Buffer.byteLength("[]\n"), + ); + }); + test("masks notification secrets on read and restores them on save", async () => { const original = [ notificationRule("webhook", "https://example.com/b2", { From 2a678ca44d63388ae0cfe31cdc6387894b6a1f0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Fri, 3 Jul 2026 09:42:53 -0500 Subject: [PATCH 6/8] fix: parse config paths from descriptors --- src/providers/b2ConfigJson.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/providers/b2ConfigJson.ts b/src/providers/b2ConfigJson.ts index cf6b2e15..e73671ff 100644 --- a/src/providers/b2ConfigJson.ts +++ b/src/providers/b2ConfigJson.ts @@ -87,12 +87,14 @@ export function parseB2ConfigPath(path: string): B2ConfigLocation | undefined { } const [bucketName, fileName] = segments; - if (!bucketName || !fileName.endsWith(B2_CONFIG_FILE_EXTENSION)) { + if (!bucketName || !fileName) { return undefined; } - const kind = fileName.slice(0, -B2_CONFIG_FILE_EXTENSION.length); - if (!isB2ConfigKind(kind)) { + const kind = B2_CONFIG_KINDS.find( + (candidate) => B2_CONFIG_KIND_DESCRIPTORS[candidate].fileName === fileName, + ); + if (!kind) { return undefined; } From f462e96c73376796ab2db40a4827fb813a492cf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Fri, 3 Jul 2026 09:50:12 -0500 Subject: [PATCH 7/8] fix: refresh config cache expiry --- src/providers/b2ConfigFileSystemProvider.ts | 6 +++- .../suite/b2ConfigFileSystemProvider.test.ts | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/providers/b2ConfigFileSystemProvider.ts b/src/providers/b2ConfigFileSystemProvider.ts index d9dd03fb..490b7318 100644 --- a/src/providers/b2ConfigFileSystemProvider.ts +++ b/src/providers/b2ConfigFileSystemProvider.ts @@ -295,7 +295,11 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs return undefined; } - const touched = { ...entry, lastAccessedAt: now }; + const touched = { + ...entry, + expiresAt: now + B2_CONFIG_CACHE_TTL_MS, + lastAccessedAt: now, + }; this.cache.set(key, touched); return touched; } diff --git a/src/test/suite/b2ConfigFileSystemProvider.test.ts b/src/test/suite/b2ConfigFileSystemProvider.test.ts index 9b467632..e83a633e 100644 --- a/src/test/suite/b2ConfigFileSystemProvider.test.ts +++ b/src/test/suite/b2ConfigFileSystemProvider.test.ts @@ -7,6 +7,7 @@ import * as assert from "assert"; import type { EventNotificationRule } from "@backblaze-labs/b2-sdk"; import { + B2_CONFIG_CACHE_TTL_MS, B2ConfigFileSystemProvider, buildB2ConfigUri, type B2ConfigBucket, @@ -210,4 +211,34 @@ suite("B2 config file-system provider", () => { assert.strictEqual(savedRules.length, 1); }); + + test("cache expiry is refreshed while config documents are accessed", async () => { + const original = [ + notificationRule("webhook", "https://example.com/b2", { + signingSecret: "signing-secret", + }), + ]; + const { provider, savedRules } = makeNotificationProvider(original); + const uri = buildB2ConfigUri("bucket", "notifications"); + const originalNow = Date.now; + let now = 1_000; + Date.now = () => now; + try { + const readConfig = JSON.parse(Buffer.from(await provider.readFile(uri)).toString("utf8")) as + | EventNotificationRule[] + | undefined; + assert.ok(readConfig); + (readConfig[0] as { objectNamePrefix: string }).objectNamePrefix = "logs/"; + + now += B2_CONFIG_CACHE_TTL_MS - 1; + provider.stat(uri); + now += 2; + + await provider.writeFile(uri, encodeConfig(readConfig), { create: false, overwrite: true }); + } finally { + Date.now = originalNow; + } + + assert.strictEqual(savedRules.length, 1); + }); }); From eba71039606d464694308a4f4f65363c772faa68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Fri, 3 Jul 2026 10:08:12 -0500 Subject: [PATCH 8/8] fix: harden config writes --- scripts/release-contract.js | 8 + src/providers/b2ConfigFileSystemProvider.ts | 82 ++++--- .../suite/b2ConfigFileSystemProvider.test.ts | 131 ++++++++++- src/test/suite/vsixAssets.test.ts | 12 + src/test/unit/b2ConfigJson.test.ts | 220 ++++++++++++++++++ 5 files changed, 410 insertions(+), 43 deletions(-) diff --git a/scripts/release-contract.js b/scripts/release-contract.js index 519d81b3..334fcfd3 100644 --- a/scripts/release-contract.js +++ b/scripts/release-contract.js @@ -27,12 +27,20 @@ const manifestContract = { "extension/resources/b2-icon.png", "extension/resources/b2-icon.svg", "extension/resources/b2-icons.woff", + "extension/resources/schemas/b2-config-bucketInfo.schema.json", + "extension/resources/schemas/b2-config-cors.schema.json", + "extension/resources/schemas/b2-config-lifecycle.schema.json", + "extension/resources/schemas/b2-config-notifications.schema.json", ], requiredInstalledFiles: [ "dist/extension.js", "resources/b2-icon.png", "resources/b2-icon.svg", "resources/b2-icons.woff", + "resources/schemas/b2-config-bucketInfo.schema.json", + "resources/schemas/b2-config-cors.schema.json", + "resources/schemas/b2-config-lifecycle.schema.json", + "resources/schemas/b2-config-notifications.schema.json", ], commandIds: [ "b2.authenticate", diff --git a/src/providers/b2ConfigFileSystemProvider.ts b/src/providers/b2ConfigFileSystemProvider.ts index 490b7318..8cc08f4c 100644 --- a/src/providers/b2ConfigFileSystemProvider.ts +++ b/src/providers/b2ConfigFileSystemProvider.ts @@ -34,6 +34,7 @@ export const B2_CONFIG_REMOTE_TIMEOUT_MS = 60_000; export const B2_CONFIG_CACHE_TTL_MS = 15 * 60 * 1000; export const B2_CONFIG_CACHE_MAX_ENTRIES = 32; export const B2_CONFIG_CACHE_MAX_BYTES = 4 * 1024 * 1024; +export const B2_CONFIG_SAVE_CONFIRM_LABEL = "Save B2 Config"; type BucketConfigUpdate = { readonly bucketInfo?: Record; @@ -91,6 +92,7 @@ export function buildB2ConfigUri(bucketName: string, kind: B2ConfigKind): vscode export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vscode.Disposable { private readonly changeEmitter = new vscode.EventEmitter(); private readonly cache = new Map(); + private readonly notificationWriteLocks = new Map>(); readonly onDidChangeFile = this.changeEmitter.event; @@ -173,10 +175,10 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs const location = this.parseUri(uri); const key = this.cacheKey(uri); const cacheEntry = this.getCacheEntry(key); - if (!cacheEntry && !options.create) { - await this.rejectWrite( - `B2: Open or reload ${b2ConfigFileName(location.kind)} before saving it.`, - ); + if (!cacheEntry) { + const message = `B2: Open or reload ${b2ConfigFileName(location.kind)} before saving it.`; + await vscode.window.showErrorMessage(message); + throw new Error(message); } if (cacheEntry && !options.overwrite) { throw vscode.FileSystemError.FileExists(uri); @@ -188,22 +190,7 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs await this.rejectWrite(`B2: ${validationError}`); } - let snapshot: B2ConfigCacheEntry; - try { - snapshot = cacheEntry ?? (await this.createWriteSnapshot(uri, location)); - } catch (error) { - if (error instanceof B2ConfigRemoteTimeoutError) { - await this.rejectWrite(`B2: ${error.message}`, error); - } - if (isBucketRevisionConflict(error)) { - await this.rejectWrite(this.conflictMessage(location), error); - } - await this.rejectWrite( - `B2: Failed to prepare ${b2ConfigFileName(location.kind)} for "${location.bucketName}". ${formatB2UserMessage(error)}`, - error, - ); - throw error; - } + const snapshot = cacheEntry; let mergedConfig: unknown; try { mergedConfig = mergeMaskedB2Config(location.kind, parsedConfig, snapshot.secretSnapshot); @@ -212,6 +199,8 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs await this.rejectWrite(`B2: ${detail}`); } + await this.confirmWrite(location); + try { const bucket = await this.resolveBucket(location, uri); const updated = await this.persistConfig(bucket, location, mergedConfig, snapshot); @@ -412,23 +401,15 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs } } - private async createWriteSnapshot( - uri: vscode.Uri, - location: B2ConfigLocation, - ): Promise { - const bucket = await this.resolveBucket(location, uri); - const { config, revision } = await this.readLiveConfig(bucket, location); - const bytes = Buffer.from( - prettyB2ConfigJson(maskB2ConfigForRead(location.kind, config)), - "utf8", + private async confirmWrite(location: B2ConfigLocation): Promise { + const confirmation = await vscode.window.showWarningMessage( + `B2: Save ${b2ConfigFileName(location.kind)} changes to bucket "${location.bucketName}"? This updates live bucket configuration.`, + { modal: true }, + B2_CONFIG_SAVE_CONFIRM_LABEL, ); - this.rememberConfigSnapshot(uri, location, config, revision, bytes.byteLength); - - const entry = this.cache.get(this.cacheKey(uri)); - if (!entry) { - throw new Error(`B2: Could not prepare ${b2ConfigFileName(location.kind)} for saving.`); + if (confirmation !== B2_CONFIG_SAVE_CONFIRM_LABEL) { + throw new Error(`B2: Save canceled for ${b2ConfigFileName(location.kind)}.`); } - return entry; } private async persistConfig( @@ -478,8 +459,29 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs return { config: updated.lifecycleRules, revision: updated.revision }; } case "notifications": - await this.assertNotificationSnapshotCurrent(bucket, location, snapshot); - return this.persistNotificationRules(bucket, location, config, snapshot.revision); + return this.serializeNotificationWrite(location, async () => { + await this.assertNotificationSnapshotCurrent(bucket, location, snapshot); + return this.persistNotificationRules(bucket, location, config, snapshot.revision); + }); + } + } + + private async serializeNotificationWrite( + location: B2ConfigLocation, + run: () => Promise, + ): Promise { + const key = `${location.bucketName}:${location.kind}`; + const previous = this.notificationWriteLocks.get(key) ?? Promise.resolve(); + // B2 notification replacement has no server-side revision precondition, so + // serialize writes in this extension host and re-check immediately before set. + const guarded = previous.catch(() => undefined).then(run); + this.notificationWriteLocks.set(key, guarded); + try { + return await guarded; + } finally { + if (this.notificationWriteLocks.get(key) === guarded) { + this.notificationWriteLocks.delete(key); + } } } @@ -571,7 +573,11 @@ export class B2ConfigFileSystemProvider implements vscode.FileSystemProvider, vs timer = setTimeout(() => { timedOut = true; timeoutError = new B2ConfigRemoteTimeoutError( - `${description} timed out after ${B2_CONFIG_REMOTE_TIMEOUT_MS} ms.`, + `${description} timed out after ${B2_CONFIG_REMOTE_TIMEOUT_MS} ms.${ + options.observeLateOutcome + ? " The request may still complete in B2; reload the document before retrying." + : "" + }`, ); if (!controller.signal.aborted) { controller.abort(timeoutError); diff --git a/src/test/suite/b2ConfigFileSystemProvider.test.ts b/src/test/suite/b2ConfigFileSystemProvider.test.ts index e83a633e..e9e20640 100644 --- a/src/test/suite/b2ConfigFileSystemProvider.test.ts +++ b/src/test/suite/b2ConfigFileSystemProvider.test.ts @@ -8,16 +8,20 @@ import * as assert from "assert"; import type { EventNotificationRule } from "@backblaze-labs/b2-sdk"; import { B2_CONFIG_CACHE_TTL_MS, + B2_CONFIG_SAVE_CONFIRM_LABEL, B2ConfigFileSystemProvider, buildB2ConfigUri, type B2ConfigBucket, type B2ConfigClient, } from "../../providers/b2ConfigFileSystemProvider"; import { + B2_CONFIG_KINDS, B2_CONFIG_MASKED_SECRET, maskB2ConfigForRead, prettyB2ConfigJson, + type B2ConfigKind, } from "../../providers/b2ConfigJson"; +import { withWindowUiStubs } from "./windowStubs"; function notificationRule( name: string, @@ -50,10 +54,21 @@ function encodeConfig(value: unknown): Uint8Array { return Buffer.from(prettyB2ConfigJson(value), "utf8"); } +async function writeConfigWithConfirmation( + provider: B2ConfigFileSystemProvider, + uri: ReturnType, + value: unknown, +): Promise { + await withWindowUiStubs({ warningValues: [B2_CONFIG_SAVE_CONFIRM_LABEL] }, async () => { + await provider.writeFile(uri, encodeConfig(value), { create: false, overwrite: true }); + }); +} + function makeNotificationProvider( initialRules: EventNotificationRule[], options: { readonly normalizeSetResult?: (rules: EventNotificationRule[]) => EventNotificationRule[]; + readonly beforeSetResult?: () => Promise; } = {}, ): { readonly bucket: B2ConfigBucket; @@ -81,6 +96,7 @@ function makeNotificationProvider( return { eventNotificationRules: state.liveRules }; }, async setNotificationRules(rules: EventNotificationRule[]) { + await options.beforeSetResult?.(); savedRules.push(rules); state.liveRules = options.normalizeSetResult?.(rules) ?? rules; return { eventNotificationRules: state.liveRules }; @@ -106,6 +122,40 @@ function makeNotificationProvider( } suite("B2 config file-system provider", () => { + test("rejects blind b2-config writes without an opened snapshot", async () => { + let getBucketCalls = 0; + const client = { + async getBucket() { + getBucketCalls += 1; + return null; + }, + } satisfies B2ConfigClient; + const provider = new B2ConfigFileSystemProvider(() => client); + const payloads = { + bucketInfo: {}, + cors: [], + lifecycle: [], + notifications: [], + } satisfies Record; + + const ui = await withWindowUiStubs({}, async () => { + for (const kind of B2_CONFIG_KINDS) { + await assert.rejects( + () => + provider.writeFile(buildB2ConfigUri("bucket", kind), encodeConfig(payloads[kind]), { + create: true, + overwrite: true, + }), + /Open or reload/u, + ); + } + }); + + assert.strictEqual(getBucketCalls, 0); + assert.strictEqual(ui.warnings.length, 0); + assert.strictEqual(ui.errors.length, B2_CONFIG_KINDS.length); + }); + test("uses kind-appropriate placeholder stat sizes before read", () => { const { provider } = makeNotificationProvider([]); @@ -147,7 +197,7 @@ suite("B2 config file-system provider", () => { assert.ok(readConfig); (readConfig[0] as { objectNamePrefix: string }).objectNamePrefix = "logs/"; - await provider.writeFile(uri, encodeConfig(readConfig), { create: false, overwrite: true }); + await writeConfigWithConfirmation(provider, uri, readConfig); assert.strictEqual(savedRules.length, 1); assert.strictEqual( @@ -178,7 +228,7 @@ suite("B2 config file-system provider", () => { assert.ok(readConfig); (readConfig[0] as { objectNamePrefix: string }).objectNamePrefix = "logs/"; - await provider.writeFile(uri, encodeConfig(readConfig), { create: false, overwrite: true }); + await writeConfigWithConfirmation(provider, uri, readConfig); const nextEdit = maskB2ConfigForRead( "notifications", @@ -186,7 +236,7 @@ suite("B2 config file-system provider", () => { ) as EventNotificationRule[]; (nextEdit[0] as { objectNamePrefix: string }).objectNamePrefix = "images/"; - await provider.writeFile(uri, encodeConfig(nextEdit), { create: false, overwrite: true }); + await writeConfigWithConfirmation(provider, uri, nextEdit); assert.strictEqual(savedRules.length, 2); }); @@ -207,8 +257,79 @@ suite("B2 config file-system provider", () => { (readConfig[0] as { objectNamePrefix: string }).objectNamePrefix = "logs/"; (bucket.info as { revision: number }).revision = 2; - await provider.writeFile(uri, encodeConfig(readConfig), { create: false, overwrite: true }); + await writeConfigWithConfirmation(provider, uri, readConfig); + + assert.strictEqual(savedRules.length, 1); + }); + + test("requires confirmation before persisting opened config documents", async () => { + const original = [ + notificationRule("webhook", "https://example.com/b2", { + signingSecret: "signing-secret", + }), + ]; + const { provider, savedRules } = makeNotificationProvider(original); + const uri = buildB2ConfigUri("bucket", "notifications"); + const readConfig = JSON.parse(Buffer.from(await provider.readFile(uri)).toString("utf8")) as + | EventNotificationRule[] + | undefined; + assert.ok(readConfig); + (readConfig[0] as { objectNamePrefix: string }).objectNamePrefix = "logs/"; + + const ui = await withWindowUiStubs({ warningValues: [undefined] }, async () => { + await assert.rejects( + () => provider.writeFile(uri, encodeConfig(readConfig), { create: false, overwrite: true }), + /Save canceled/u, + ); + }); + + assert.strictEqual(savedRules.length, 0); + assert.strictEqual(ui.warnings.length, 1); + assert.strictEqual(ui.warnings[0]?.options?.modal, true); + assert.deepStrictEqual(ui.warnings[0]?.items, [B2_CONFIG_SAVE_CONFIRM_LABEL]); + assert.match(ui.warnings[0]?.message ?? "", /updates live bucket configuration/u); + }); + + test("serializes concurrent notification replacements through stale checks", async () => { + let inFlightSets = 0; + let maxInFlightSets = 0; + const original = [ + notificationRule("webhook", "https://example.com/b2", { + signingSecret: "signing-secret", + }), + ]; + const { provider, savedRules } = makeNotificationProvider(original, { + beforeSetResult: async () => { + inFlightSets += 1; + maxInFlightSets = Math.max(maxInFlightSets, inFlightSets); + await new Promise((resolve) => setTimeout(resolve, 10)); + inFlightSets -= 1; + }, + }); + const uri = buildB2ConfigUri("bucket", "notifications"); + const readConfig = JSON.parse(Buffer.from(await provider.readFile(uri)).toString("utf8")) as + | EventNotificationRule[] + | undefined; + assert.ok(readConfig); + const firstEdit = JSON.parse(JSON.stringify(readConfig)) as EventNotificationRule[]; + const secondEdit = JSON.parse(JSON.stringify(readConfig)) as EventNotificationRule[]; + (firstEdit[0] as { objectNamePrefix: string }).objectNamePrefix = "logs/"; + (secondEdit[0] as { objectNamePrefix: string }).objectNamePrefix = "images/"; + + let results: PromiseSettledResult[] = []; + await withWindowUiStubs( + { warningValues: [B2_CONFIG_SAVE_CONFIRM_LABEL, B2_CONFIG_SAVE_CONFIRM_LABEL] }, + async () => { + results = await Promise.allSettled([ + provider.writeFile(uri, encodeConfig(firstEdit), { create: false, overwrite: true }), + provider.writeFile(uri, encodeConfig(secondEdit), { create: false, overwrite: true }), + ]); + }, + ); + assert.strictEqual(results.filter((result) => result.status === "fulfilled").length, 1); + assert.strictEqual(results.filter((result) => result.status === "rejected").length, 1); + assert.strictEqual(maxInFlightSets, 1); assert.strictEqual(savedRules.length, 1); }); @@ -234,7 +355,7 @@ suite("B2 config file-system provider", () => { provider.stat(uri); now += 2; - await provider.writeFile(uri, encodeConfig(readConfig), { create: false, overwrite: true }); + await writeConfigWithConfirmation(provider, uri, readConfig); } finally { Date.now = originalNow; } diff --git a/src/test/suite/vsixAssets.test.ts b/src/test/suite/vsixAssets.test.ts index 1413ee59..22776ed0 100644 --- a/src/test/suite/vsixAssets.test.ts +++ b/src/test/suite/vsixAssets.test.ts @@ -95,6 +95,12 @@ interface FakeRequest extends EventEmitter { const SQL_JS_RUNTIME_FIXTURE_PATH = resolveSqlJsRuntimeSourcePath(process.cwd()); const SQL_WASM_FIXTURE_PATH = resolveSqlWasmSourcePath(process.cwd()); const FIXTURE_ASSERT_OPTIONS = { skipSqlJsPackageProvenance: true }; +const B2_CONFIG_SCHEMA_PACKAGE_ENTRIES = [ + "extension/resources/schemas/b2-config-bucketInfo.schema.json", + "extension/resources/schemas/b2-config-cors.schema.json", + "extension/resources/schemas/b2-config-lifecycle.schema.json", + "extension/resources/schemas/b2-config-notifications.schema.json", +] as const; function loadVsixAssetAssertions(): VsixAssetAssertions { return require(path.join(process.cwd(), "scripts/assert-vsix-assets.js")) as VsixAssetAssertions; @@ -169,6 +175,12 @@ function baseEntries( "extension/resources/b2-icon.png": "png", "extension/resources/b2-icon.svg": "", "extension/resources/b2-icons.woff": "woff", + ...Object.fromEntries( + B2_CONFIG_SCHEMA_PACKAGE_ENTRIES.map((entry) => [ + entry, + fs.readFileSync(path.join(process.cwd(), entry.replace(/^extension\//u, ""))), + ]), + ), }; } diff --git a/src/test/unit/b2ConfigJson.test.ts b/src/test/unit/b2ConfigJson.test.ts index 6d7f291c..56273b77 100644 --- a/src/test/unit/b2ConfigJson.test.ts +++ b/src/test/unit/b2ConfigJson.test.ts @@ -40,6 +40,105 @@ interface TestNotificationRule { }; } +interface TestJsonSchema { + readonly type?: string; + readonly anyOf?: readonly TestJsonSchema[]; + readonly enum?: readonly unknown[]; + readonly items?: TestJsonSchema; + readonly properties?: Readonly>; + readonly required?: readonly string[]; + readonly additionalProperties?: boolean | TestJsonSchema; + readonly uniqueItems?: boolean; + readonly minimum?: number; + readonly format?: string; +} + +function isSchemaType(value: unknown, type: string): boolean { + switch (type) { + case "array": + return Array.isArray(value); + case "boolean": + return typeof value === "boolean"; + case "integer": + return Number.isInteger(value); + case "null": + return value === null; + case "number": + return typeof value === "number"; + case "object": + return typeof value === "object" && value !== null && !Array.isArray(value); + case "string": + return typeof value === "string"; + default: + throw new Error(`Unsupported schema type ${type}`); + } +} + +function schemaAccepts(schema: TestJsonSchema, value: unknown): boolean { + if (schema.anyOf) { + return schema.anyOf.some((candidate) => schemaAccepts(candidate, value)); + } + if (schema.type && !isSchemaType(value, schema.type)) { + return false; + } + if (schema.enum && !schema.enum.some((item) => item === value)) { + return false; + } + if (typeof schema.minimum === "number" && typeof value === "number" && value < schema.minimum) { + return false; + } + if (schema.format === "uri" && typeof value === "string") { + try { + new URL(value); + } catch { + return false; + } + } + if (Array.isArray(value)) { + if (schema.uniqueItems) { + const unique = new Set(value.map(stableB2ConfigJson)); + if (unique.size !== value.length) { + return false; + } + } + return schema.items === undefined || value.every((item) => schemaAccepts(schema.items!, item)); + } + if (typeof value === "object" && value !== null) { + const record = value as Record; + for (const requiredKey of schema.required ?? []) { + if (!(requiredKey in record)) { + return false; + } + } + for (const [key, item] of Object.entries(record)) { + const propertySchema = schema.properties?.[key]; + if (propertySchema) { + if (!schemaAccepts(propertySchema, item)) { + return false; + } + } else if (schema.additionalProperties === false) { + return false; + } else if ( + typeof schema.additionalProperties === "object" && + !schemaAccepts(schema.additionalProperties, item) + ) { + return false; + } + } + } + + return true; +} + +function readB2ConfigSchema(kind: (typeof B2_CONFIG_KINDS)[number]): TestJsonSchema { + return JSON.parse( + fs.readFileSync( + path.join(process.cwd(), b2ConfigSchemaPath(kind).replace(/^\.\//u, "")), + "utf8", + ), + ) as TestJsonSchema; +} + function notificationRule( name: string, url: string, @@ -101,6 +200,9 @@ test("masks notification signing secrets and all custom headers on read", () => headers: { Authorization: "Bearer secret", "X-Api-Key": "api-key", + "x-auth-token": "auth-token", + Cookie: "session=secret", + "X-Signature": "signature", "X-Webhook-Secret": "webhook-secret", "X-Trace": "trace-token", }, @@ -114,6 +216,9 @@ test("masks notification signing secrets and all custom headers on read", () => assert.deepEqual(masked[0].targetConfiguration.customHeaders, { Authorization: B2_CONFIG_MASKED_SECRET, "X-Api-Key": B2_CONFIG_MASKED_SECRET, + "x-auth-token": B2_CONFIG_MASKED_SECRET, + Cookie: B2_CONFIG_MASKED_SECRET, + "X-Signature": B2_CONFIG_MASKED_SECRET, "X-Webhook-Secret": B2_CONFIG_MASKED_SECRET, "X-Trace": B2_CONFIG_MASKED_SECRET, }); @@ -408,6 +513,121 @@ test("validates config shapes before save", () => { ); }); +test("save validation stays aligned with bundled JSON schemas", () => { + const fixtures: Array<{ + readonly kind: (typeof B2_CONFIG_KINDS)[number]; + readonly label: string; + readonly valid: boolean; + readonly value: unknown; + }> = [ + { + kind: "bucketInfo", + label: "valid bucket info", + valid: true, + value: { team: "platform" }, + }, + { + kind: "bucketInfo", + label: "invalid bucket info value", + valid: false, + value: { team: 1 }, + }, + { + kind: "lifecycle", + label: "valid lifecycle rule", + valid: true, + value: [ + { + fileNamePrefix: "logs/", + daysFromUploadingToHiding: 30, + daysFromHidingToDeleting: null, + }, + ], + }, + { + kind: "lifecycle", + label: "invalid lifecycle minimum", + valid: false, + value: [ + { + fileNamePrefix: "logs/", + daysFromUploadingToHiding: 0, + daysFromHidingToDeleting: null, + }, + ], + }, + { + kind: "cors", + label: "valid CORS rule", + valid: true, + value: [ + { + corsRuleName: "browser", + allowedOrigins: ["https://example.com"], + allowedOperations: ["b2_download_file_by_name"], + allowedHeaders: null, + exposeHeaders: null, + maxAgeSeconds: 300, + }, + ], + }, + { + kind: "cors", + label: "invalid CORS operation", + valid: false, + value: [ + { + corsRuleName: "browser", + allowedOrigins: ["https://example.com"], + allowedOperations: ["unsupported"], + allowedHeaders: null, + exposeHeaders: null, + maxAgeSeconds: 300, + }, + ], + }, + { + kind: "notifications", + label: "valid notification rule", + valid: true, + value: [notificationRule("webhook", "https://example.com/b2")], + }, + { + kind: "notifications", + label: "invalid notification URL", + valid: false, + value: [notificationRule("webhook", "not a url")], + }, + { + kind: "notifications", + label: "invalid notification duplicate event", + valid: false, + value: [ + { + ...notificationRule("webhook", "https://example.com/b2"), + eventTypes: ["b2:ObjectCreated:*", "b2:ObjectCreated:*"], + }, + ], + }, + ]; + + const schemas = Object.fromEntries( + B2_CONFIG_KINDS.map((kind) => [kind, readB2ConfigSchema(kind)]), + ); + for (const fixture of fixtures) { + assert.equal( + schemaAccepts(schemas[fixture.kind], fixture.value), + fixture.valid, + `${fixture.label} should ${fixture.valid ? "match" : "fail"} schema validation`, + ); + assert.equal( + validateB2ConfigJson(fixture.kind, fixture.value) === undefined, + fixture.valid, + `${fixture.label} should ${fixture.valid ? "match" : "fail"} save validation`, + ); + } +}); + test("stable config JSON and fingerprint ignore object key insertion order", () => { const left = { b: 1, a: { d: 4, c: 3 } }; const right = {