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..a0a92e1d --- /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. Header values are masked on read.", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["targetType", "url"] + } + }, + "required": [ + "name", + "eventTypes", + "isEnabled", + "isSuspended", + "objectNamePrefix", + "suspensionReason", + "targetConfiguration" + ] + }, + "default": [] +} diff --git a/scripts/release-contract.js b/scripts/release-contract.js index 9c0ecc1b..334fcfd3 100644 --- a/scripts/release-contract.js +++ b/scripts/release-contract.js @@ -21,18 +21,26 @@ 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", "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", @@ -59,7 +67,7 @@ const manifestContract = { "b2_deleteFile", "b2_presignUrl", ], - contributesSha256: "40a5f09a30986dfcb974032113ec6afaa2a694fa9fd12421b222955dac64eda7", + contributesSha256: "ded59c90229a7abbf10894fb367ca48e6d4040a8069848ff076e7cf88cfd5b2c", }; function stableStringify(value) { diff --git a/src/extension.ts b/src/extension.ts index 092e4387..521777e3 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,8 +224,28 @@ export async function activate(context: vscode.ExtensionContext): Promise // 4. Status bar const statusBar = new B2StatusBar(authService); - // 5. Register commands - const setAuthenticatedClient = createAuthenticatedClientSetter(); + // 5. Register virtual bucket-configuration documents + const configFileSystemProvider = new B2ConfigFileSystemProvider(() => currentClient); + context.subscriptions.push( + vscode.workspace.registerFileSystemProvider(B2_CONFIG_SCHEME, configFileSystemProvider, { + isCaseSensitive: true, + }), + configFileSystemProvider, + vscode.workspace.onDidCloseTextDocument((document) => { + if (document.uri.scheme === B2_CONFIG_SCHEME) { + configFileSystemProvider.deleteCacheEntry(document.uri); + } + }), + ); + + // 6. Register commands + const setAuthenticatedClient = createAuthenticatedClientSetter( + scheduleAuthenticatedCleanups, + (client) => { + currentClient = client; + configFileSystemProvider.clearCache(); + }, + ); registerCommands({ context, authService, @@ -233,11 +257,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..8cc08f4c --- /dev/null +++ b/src/providers/b2ConfigFileSystemProvider.ts @@ -0,0 +1,628 @@ +/** + * 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 { log, logError } from "../logger"; +import { + B2_CONFIG_KINDS, + B2_CONFIG_SCHEME, + b2ConfigFileName, + createB2ConfigSecretSnapshot, + fingerprintB2ConfigJson, + maskB2ConfigForRead, + mergeMaskedB2Config, + parseB2ConfigPath, + prettyB2ConfigJson, + 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; +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; + 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 originalFingerprint: string; + readonly secretSnapshot?: B2ConfigSecretSnapshot; + readonly revision: number; + readonly mtime: number; + readonly size: number; + readonly expiresAt: number; + readonly lastAccessedAt: number; +} + +class B2ConfigConflictError extends Error { + constructor(message: string) { + super(message); + this.name = "B2ConfigConflictError"; + } +} + +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, + path: `/${bucketName}/${b2ConfigFileName(kind)}`, + }); +} + +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; + + 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, + }; + } + + 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(defaultConfig)), + }; + } + + 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); + 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.rememberConfigSnapshot(uri, location, config, revision, bytes.byteLength); + + return bytes; + } catch (error) { + if (error instanceof B2ConfigRemoteTimeoutError) { + await vscode.window.showErrorMessage(`B2: ${error.message}`); + } + throw error; + } + } + + 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.getCacheEntry(key); + 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); + } + + const parsedConfig = await this.parseEditedJson(location, content); + const validationError = validateB2ConfigJson(location.kind, parsedConfig); + if (validationError) { + await this.rejectWrite(`B2: ${validationError}`); + } + + const snapshot = cacheEntry; + 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}`); + } + + await this.confirmWrite(location); + + try { + 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.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); + } + 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(); + } + + 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); + } + + const location = parseB2ConfigPath(uri.path); + if (!location) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + return location; + } + + private cacheKey(uri: vscode.Uri): string { + return uri.toString(); + } + + 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, + expiresAt: now + B2_CONFIG_CACHE_TTL_MS, + 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); + } + } + + 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, + ); + 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; + } + } + + 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 this.withRemoteTimeout(location, "fetch bucket metadata", () => + client.getBucket(location.bucketName), + ); + if (!bucket) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + return bucket; + } + + private async readLiveConfig( + bucket: B2ConfigBucket, + 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 (location.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 this.withRemoteTimeout(location, "read notification rules", () => + 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 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, + ); + if (confirmation !== B2_CONFIG_SAVE_CONFIRM_LABEL) { + throw new Error(`B2: Save canceled for ${b2ConfigFileName(location.kind)}.`); + } + } + + private async persistConfig( + bucket: B2ConfigBucket, + location: B2ConfigLocation, + config: unknown, + snapshot: B2ConfigCacheEntry, + ): Promise<{ readonly config: unknown; readonly revision: number }> { + switch (location.kind) { + case "bucketInfo": { + 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 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 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": + 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); + } + } + } + + private async persistNotificationRules( + bucket: B2ConfigBucket, + location: B2ConfigLocation, + config: unknown, + revision: number, + ): Promise<{ readonly config: unknown; readonly revision: number }> { + try { + 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) { + return { config: reconciled, revision }; + } + throw error; + } + } + + 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 { + 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.${ + options.observeLateOutcome + ? " The request may still complete in B2; reload the document before retrying." + : "" + }`, + ); + 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.`; + } + + 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..e73671ff --- /dev/null +++ b/src/providers/b2ConfigJson.ts @@ -0,0 +1,636 @@ +/** + * Shared JSON helpers for virtual B2 bucket-configuration documents. + * + * @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_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 Object.prototype.hasOwnProperty.call(B2_CONFIG_KIND_DESCRIPTORS, value); +} + +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) { + return undefined; + } + + const kind = B2_CONFIG_KINDS.find( + (candidate) => B2_CONFIG_KIND_DESCRIPTORS[candidate].fileName === fileName, + ); + if (!kind) { + return undefined; + } + + return { bucketName, kind }; +} + +export function b2ConfigFileName(kind: B2ConfigKind): string { + 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 { + return `${JSON.stringify(value, null, 2) ?? "null"}\n`; +} + +export function cloneB2ConfigJson(value: unknown): unknown { + 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 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 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); +} + +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 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(), + ); +} + +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 NOTIFICATION_SECRET_KEYS) { + 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 (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 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)) { + return merged; + } + + const usedMaskedIdentities = new Set(); + for (const [index, rule] of merged.entries()) { + if (!hasNotificationMask(rule)) { + continue; + } + + 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 = 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; + if (!isRecord(editedHeaders)) { + continue; + } + + for (const [headerName, headerValue] of Object.entries(editedHeaders)) { + if (headerValue !== B2_CONFIG_MASKED_SECRET) { + continue; + } + 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]; + } + } + + return merged; +} + +export function mergeMaskedB2Config( + kind: B2ConfigKind, + edited: unknown, + secretSnapshot?: B2ConfigSecretSnapshot, +): unknown { + return kind === "notifications" + ? mergeNotificationSecrets(edited, secretSnapshot) + : cloneB2ConfigJson(edited); +} + +function validateLifecycleRule(rule: unknown, index: number): string | undefined { + const path = `lifecycle[${index}]`; + if (!isRecord(rule)) { + return `${path} must be an object.`; + } + + 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 validateCorsRule(rule: unknown, index: number): string | undefined { + const path = `cors[${index}]`; + if (!isRecord(rule)) { + return `${path} must be an object.`; + } + + const operationError = validateStringArray(rule.allowedOperations, `${path}.allowedOperations`); + if (operationError) { + 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), + ); + + 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 validateNotificationRule(rule: unknown, index: number): string | undefined { + const path = `notifications[${index}]`; + if (!isRecord(rule)) { + return `${path} must be an object.`; + } + + const eventTypeError = validateStringArray(rule.eventTypes, `${path}.eventTypes`); + if (eventTypeError) { + 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), + ); + 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 `${path}.targetConfiguration.customHeaders must be an object.`; + } + for (const [headerName, headerValue] of Object.entries(customHeaders)) { + if (typeof headerValue !== "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; +} + +export function validateB2ConfigJson(kind: B2ConfigKind, value: unknown): string | undefined { + switch (kind) { + case "bucketInfo": + return validateBucketInfo(value); + case "cors": + return validateArrayConfig(kind, value, validateCorsRule); + case "lifecycle": + return validateArrayConfig(kind, value, validateLifecycleRule); + case "notifications": + return validateArrayConfig(kind, value, validateNotificationRule); + } +} + +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)) ?? "null"; +} + +export function fingerprintB2ConfigJson(value: unknown): string { + return createHash("sha256").update(stableB2ConfigJson(value)).digest("hex"); +} diff --git a/src/test/suite/b2ConfigFileSystemProvider.test.ts b/src/test/suite/b2ConfigFileSystemProvider.test.ts new file mode 100644 index 00000000..e9e20640 --- /dev/null +++ b/src/test/suite/b2ConfigFileSystemProvider.test.ts @@ -0,0 +1,365 @@ +/** + * 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 { + 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, + 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"); +} + +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; + 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[]) { + await options.beforeSetResult?.(); + 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("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([]); + + 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", { + 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 writeConfigWithConfirmation(provider, uri, readConfig); + + 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 writeConfigWithConfirmation(provider, uri, readConfig); + + const nextEdit = maskB2ConfigForRead( + "notifications", + fixture.liveRules, + ) as EventNotificationRule[]; + (nextEdit[0] as { objectNamePrefix: string }).objectNamePrefix = "images/"; + + await writeConfigWithConfirmation(provider, uri, nextEdit); + + 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 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); + }); + + 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 writeConfigWithConfirmation(provider, uri, readConfig); + } finally { + Date.now = originalNow; + } + + assert.strictEqual(savedRules.length, 1); + }); +}); 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 new file mode 100644 index 00000000..56273b77 --- /dev/null +++ b/src/test/unit/b2ConfigJson.test.ts @@ -0,0 +1,670 @@ +/** + * Unit tests for B2 bucket-configuration JSON helpers. + * + * @module test/unit/b2ConfigJson + */ + +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, + prettyB2ConfigJson, + stableB2ConfigJson, + 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; + }; +} + +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, + 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", + 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 all custom headers on read", () => { + const original = [ + notificationRule("webhook", "https://example.com/b2", { + signingSecret: "signing-secret", + legacySecret: "legacy-secret", + 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", + }, + }), + ]; + + 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.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, + }); + assert.equal(original[0].targetConfiguration.hmacSha256SigningSecret, "signing-secret"); +}); + +test("merges unchanged notification masks back to matching original rule secrets", () => { + const original = [ + notificationRule("webhook", "https://example.com/b2", { + signingSecret: "signing-secret", + headers: { + Authorization: "Bearer secret", + "X-Api-Key": "api-key", + }, + }), + ]; + const edited = maskedNotifications(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 = [ + notificationRule("webhook", "https://example.com/b2", { + signingSecret: "old-secret", + headers: { + Authorization: "Bearer old", + }, + }), + ]; + const edited = maskedNotifications(original); + edited[0].targetConfiguration.hmacSha256SigningSecret = "new-secret"; + + 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"); +}); + +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("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", [ + { + ...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("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 = { + a: { c: 3, d: 4 }, + b: 1, + }; + + assert.equal(stableB2ConfigJson(left), stableB2ConfigJson(right)); + 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 { + 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)}`]); + } +});