diff --git a/graphile/graphile-settings/src/presigned-url-resolver.ts b/graphile/graphile-settings/src/presigned-url-resolver.ts index cd6eb22c02..4e1728fd1a 100644 --- a/graphile/graphile-settings/src/presigned-url-resolver.ts +++ b/graphile/graphile-settings/src/presigned-url-resolver.ts @@ -11,11 +11,10 @@ * Follows the same lazy-init pattern as upload-resolver.ts. */ -import { BucketProvisioner } from '@constructive-io/bucket-provisioner'; +import { BucketProvisioner, mintPhysicalBucketName } from '@constructive-io/bucket-provisioner'; import { getEnvOptions } from '@constructive-io/graphql-env'; import { createS3Client } from '@constructive-io/s3-utils'; import { Logger } from '@pgpmjs/logger'; -import { createHash } from 'crypto'; import type { BucketNameResolver as ProvisionerBucketNameResolver } from 'graphile-bucket-provisioner-plugin'; import type { BucketNameResolver, EnsureBucketProvisioned,S3Config } from 'graphile-presigned-url-plugin'; @@ -109,68 +108,6 @@ function getBucketNamePrefix(): string { return prefix; } -/** S3's hard ceiling on a bucket name. */ -const MAX_BUCKET_NAME_LENGTH = 63; -/** S3's floor, which a degenerate prefix/key could otherwise fall under. */ -const MIN_BUCKET_NAME_LENGTH = 3; -/** Hex characters of the identity digest kept as the uniqueness tail. */ -const IDENTITY_DIGEST_LENGTH = 12; -/** Readable budget: how much of the name the prefix and key may each occupy. */ -const PREFIX_BUDGET = 20; -const BUCKET_KEY_BUDGET = 63 - IDENTITY_DIGEST_LENGTH - PREFIX_BUDGET - 3; - -/** - * Reduce a component to the S3 bucket-name alphabet: lowercase, `[a-z0-9-]`, - * with runs of separators collapsed and no leading or trailing hyphen. - * - * Dots are legal in a bucket name but deliberately dropped — a dotted name - * cannot be used with virtual-hosted-style HTTPS, because the wildcard - * certificate does not match a further label. - */ -function sanitizeBucketNameComponent(value: string): string { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); -} - -/** - * The single physical-bucket naming policy: - * `{prefix}-{bucketKey}-{digest}` (e.g. `myapp-public-3f9c1a2b7e04`). - * - * Both the presigned-upload (lazy) path and the bucket-provisioner (eager) path - * derive names from this one function, so a bucket's physical name is identical - * regardless of which path mints it. - * - * The name is bounded and S3-legal by construction: the prefix and key are - * sanitized to `[a-z0-9-]` and truncated to a readable budget, and the tail is a - * digest of the *untruncated* identity — so two buckets whose keys agree only - * past the truncation point, or the same key in two databases, still get distinct - * names. Names remain stable for a given (prefix, databaseId, bucketKey) because - * nothing here reads the clock or a counter; and an already-provisioned bucket - * never consults this function at all, since `platform_buckets.physical_name` is - * authoritative once recorded. - */ -function mintPhysicalBucketName(prefix: string, databaseId: string, bucketKey: string): string { - const identity = `${prefix}/${databaseId}/${bucketKey}`; - const digest = createHash('sha256').update(identity).digest('hex').slice(0, IDENTITY_DIGEST_LENGTH); - - const safePrefix = sanitizeBucketNameComponent(prefix).slice(0, PREFIX_BUDGET).replace(/-+$/, ''); - const safeKey = sanitizeBucketNameComponent(bucketKey).slice(0, BUCKET_KEY_BUDGET).replace(/-+$/, ''); - - const name = [safePrefix, safeKey, digest].filter((part) => part.length > 0).join('-'); - - // The digest alone already satisfies both bounds, so this is only reachable - // when both readable components sanitize away to nothing. - if (name.length < MIN_BUCKET_NAME_LENGTH || name.length > MAX_BUCKET_NAME_LENGTH) { - throw new Error( - `[presigned-url-resolver] Cannot mint a legal S3 bucket name for key "${bucketKey}": got "${name}"`, - ); - } - - return name; -} - /** * Create a per-(database, bucketKey) bucket name resolver for the presigned * URL plugin (argument order: `(databaseId, bucketKey)`). diff --git a/packages/bucket-provisioner/__tests__/naming.test.ts b/packages/bucket-provisioner/__tests__/naming.test.ts new file mode 100644 index 0000000000..12aa9db7a9 --- /dev/null +++ b/packages/bucket-provisioner/__tests__/naming.test.ts @@ -0,0 +1,56 @@ +import { mintPhysicalBucketName } from '../src/naming'; + +const PREFIX = 'test-bucket'; +const DATABASE_ID = '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9'; +const BUCKET_NAME = /^[a-z0-9-]{3,63}$/; + +describe('mintPhysicalBucketName', () => { + it('returns the same name for repeated calls with the same identity', () => { + const first = mintPhysicalBucketName(PREFIX, DATABASE_ID, 'default'); + const second = mintPhysicalBucketName(PREFIX, DATABASE_ID, 'default'); + + expect(second).toBe(first); + }); + + it('separates the same bucket key across databases', () => { + expect( + mintPhysicalBucketName(PREFIX, DATABASE_ID, 'default'), + ).not.toBe( + mintPhysicalBucketName(PREFIX, '11111111-2222-3333-4444-555555555555', 'default'), + ); + }); + + it('separates keys that differ only past the readable budget', () => { + const shared = 'a'.repeat(40); + + expect( + mintPhysicalBucketName(PREFIX, DATABASE_ID, `${shared}-one`), + ).not.toBe( + mintPhysicalBucketName(PREFIX, DATABASE_ID, `${shared}-two`), + ); + }); + + it('always returns a bounded S3 bucket name without edge hyphens', () => { + const name = mintPhysicalBucketName( + 'Some_Very.Long CDN Prefix That Nobody Would Choose', + DATABASE_ID, + 'Marketing_Site/Assets — 2024'.repeat(5), + ); + + expect(name).toMatch(BUCKET_NAME); + expect(name).not.toMatch(/^-|-$/); + }); + + it('keeps a key that sanitizes to empty legal', () => { + const name = mintPhysicalBucketName(PREFIX, DATABASE_ID, '!!!'); + + expect(name).toMatch(BUCKET_NAME); + expect(name).not.toMatch(/^-|-$/); + }); + + it('falls back to the digest alone when both components sanitize away', () => { + const name = mintPhysicalBucketName('!!!', DATABASE_ID, '???'); + + expect(name).toMatch(/^[a-f0-9]{12}$/); + }); +}); diff --git a/packages/bucket-provisioner/src/index.ts b/packages/bucket-provisioner/src/index.ts index a9e625489d..551a226ba2 100644 --- a/packages/bucket-provisioner/src/index.ts +++ b/packages/bucket-provisioner/src/index.ts @@ -31,6 +31,9 @@ export type { BucketProvisionerOptions } from './provisioner'; export { BucketProvisioner } from './provisioner'; +// Physical naming policy +export { mintPhysicalBucketName } from './naming'; + // S3 client factory export { createS3Client } from './client'; diff --git a/packages/bucket-provisioner/src/naming.ts b/packages/bucket-provisioner/src/naming.ts new file mode 100644 index 0000000000..a7102fe05c --- /dev/null +++ b/packages/bucket-provisioner/src/naming.ts @@ -0,0 +1,63 @@ +import { createHash } from 'crypto'; + +/** S3's hard ceiling on a bucket name. */ +const MAX_BUCKET_NAME_LENGTH = 63; +/** S3's floor, which a degenerate prefix/key could otherwise fall under. */ +const MIN_BUCKET_NAME_LENGTH = 3; +/** Hex characters of the identity digest kept as the uniqueness tail. */ +const IDENTITY_DIGEST_LENGTH = 12; +/** Readable budget: how much of the name the prefix and key may each occupy. */ +const PREFIX_BUDGET = 20; +const BUCKET_KEY_BUDGET = 63 - IDENTITY_DIGEST_LENGTH - PREFIX_BUDGET - 3; + +/** + * Reduce a component to the S3 bucket-name alphabet: lowercase, `[a-z0-9-]`, + * with runs of separators collapsed and no leading or trailing hyphen. + * + * Dots are legal in a bucket name but deliberately dropped — a dotted name + * cannot be used with virtual-hosted-style HTTPS, because the wildcard + * certificate does not match a further label. + */ +function sanitizeBucketNameComponent(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** + * The single physical-bucket naming policy: + * `{prefix}-{bucketKey}-{digest}` (e.g. `myapp-public-3f9c1a2b7e04`). + * + * Both the presigned-upload (lazy) path and the bucket-provisioner (eager) path + * derive names from this one function, so a bucket's physical name is identical + * regardless of which path mints it. + * + * The name is bounded and S3-legal by construction: the prefix and key are + * sanitized to `[a-z0-9-]` and truncated to a readable budget, and the tail is + * a digest of the *untruncated* identity — so two buckets whose keys agree only + * past the truncation point, or the same key in two databases, still get distinct + * names. Names remain stable for a given (prefix, databaseId, bucketKey) because + * nothing here reads the clock or a counter; and an already-provisioned bucket + * never consults this function at all, since `platform_buckets.physical_name` is + * authoritative once recorded. + */ +export function mintPhysicalBucketName(prefix: string, databaseId: string, bucketKey: string): string { + const identity = `${prefix}/${databaseId}/${bucketKey}`; + const digest = createHash('sha256').update(identity).digest('hex').slice(0, IDENTITY_DIGEST_LENGTH); + + const safePrefix = sanitizeBucketNameComponent(prefix).slice(0, PREFIX_BUDGET).replace(/-+$/, ''); + const safeKey = sanitizeBucketNameComponent(bucketKey).slice(0, BUCKET_KEY_BUDGET).replace(/-+$/, ''); + + const name = [safePrefix, safeKey, digest].filter((part) => part.length > 0).join('-'); + + // The digest alone already satisfies both bounds, so this is only reachable + // when both readable components sanitize away to nothing. + if (name.length < MIN_BUCKET_NAME_LENGTH || name.length > MAX_BUCKET_NAME_LENGTH) { + throw new Error( + `[bucket-provisioner] Cannot mint a legal S3 bucket name for key "${bucketKey}": got "${name}"`, + ); + } + + return name; +}