Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 1 addition & 64 deletions graphile/graphile-settings/src/presigned-url-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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)`).
Expand Down
56 changes: 56 additions & 0 deletions packages/bucket-provisioner/__tests__/naming.test.ts
Original file line number Diff line number Diff line change
@@ -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}$/);
});
});
3 changes: 3 additions & 0 deletions packages/bucket-provisioner/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
63 changes: 63 additions & 0 deletions packages/bucket-provisioner/src/naming.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading