mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Plan U1 / spec §15.6.
apps/daemon/src/storage/aws-sigv4.ts ships a minimal AWS Signature
V4 signer using only node:crypto. No `@aws-sdk/*` dep is pulled
in; the OD daemon ships without an extra ~60 MB of SDK code on
disk.
signSigV4({ method, path, query, headers, body, region, service,
credentials, now? }) \u2192 { authorization, amzDate,
contentSha256 }
The signer mutates the supplied headers map by adding the four
required amz fields ('x-amz-date', 'x-amz-content-sha256',
optional 'x-amz-security-token', and the final 'authorization').
The 'now' override pins signatures for tests.
apps/daemon/src/storage/project-storage.ts S3ProjectStorage now
implements the five operations the ProjectStorage interface
declares — all five round-trip through pluggable fetch:
readFile \u2192 GET /<key>
writeFile \u2192 PUT /<key> (with x-amz-content-sha256)
deleteFile \u2192 DELETE /<key> (idempotent on 404)
statFile \u2192 HEAD /<key> (returns null on 404)
listFiles \u2192 GET /?list-type=2&prefix=<projectId>/
walks NextContinuationToken for paginated buckets.
Style:
- Default to virtual-host-style endpoints (`<bucket>.s3.<region>.amazonaws.com`).
- When OD_S3_ENDPOINT is set, switch to path-style (`<endpoint>/<bucket>/<key>`) for
Aliyun OSS / Tencent COS / Huawei OBS / MinIO.
Credentials are read in this order:
OD_S3_ACCESS_KEY_ID / OD_S3_SECRET_ACCESS_KEY / OD_S3_SESSION_TOKEN
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN
\u2026so existing AWS toolchain setups (`aws configure` exporters,
IAM-role pods) drop in without renaming.
Daemon tests: 1650 \u2192 1661 (+11).
+4 plugins-aws-sigv4 cases:
- AWS-published reference vector (GetObject) hashes byte-equal.
- List-bucket query stays canonical-sorted.
- x-amz-security-token forwards through SignedHeaders.
- encodeS3PathSegment handles unreserved / reserved chars.
+7 storage S3 cases (replacing the prior throw-only stub):
- PUT signs + reports ProjectFileMeta back.
- GET returns body bytes; 404 \u2192 NOT_FOUND.
- HEAD returns null on 404 + parses Content-Length / Last-Modified.
- DELETE swallows 404, rejects 500.
- LIST parses ListBucketV2 XML + walks NextContinuationToken.
- endpoint override switches to path-style.
- resolveProjectStorage falls back to AWS_* env vars when OD_*
knobs are unset.
Co-authored-by: Tom Huang <1043269994@qq.com>
88 lines
3.4 KiB
TypeScript
88 lines
3.4 KiB
TypeScript
// Phase 5 / spec §15.6 / plan §3.U1 — SigV4 signer correctness.
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import { encodeS3PathSegment, signSigV4 } from '../src/storage/aws-sigv4.js';
|
|
|
|
describe('signSigV4', () => {
|
|
it('produces the AWS-documented signature for the GET-object reference request', () => {
|
|
// Reference vector adapted from
|
|
// https://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html
|
|
// (GetObject, with header-based auth, range header)
|
|
const headers: Record<string, string> = {
|
|
'host': 'examplebucket.s3.amazonaws.com',
|
|
'range': 'bytes=0-9',
|
|
};
|
|
const result = signSigV4({
|
|
method: 'GET',
|
|
path: '/test.txt',
|
|
query: '',
|
|
headers,
|
|
body: Buffer.alloc(0),
|
|
region: 'us-east-1',
|
|
service: 's3',
|
|
credentials: {
|
|
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
|
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
|
},
|
|
now: new Date('2013-05-24T00:00:00Z'),
|
|
});
|
|
// The published reference signature.
|
|
expect(result.authorization).toBe(
|
|
'AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, ' +
|
|
'SignedHeaders=host;range;x-amz-content-sha256;x-amz-date, ' +
|
|
'Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41',
|
|
);
|
|
expect(result.amzDate).toBe('20130524T000000Z');
|
|
expect(result.contentSha256).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
|
|
});
|
|
|
|
it('signs an empty-key listing request with sorted canonical query', () => {
|
|
const headers: Record<string, string> = { 'host': 'od-bucket.s3.us-east-1.amazonaws.com' };
|
|
const result = signSigV4({
|
|
method: 'GET',
|
|
path: '/',
|
|
query: 'list-type=2&prefix=p1%2F',
|
|
headers,
|
|
body: Buffer.alloc(0),
|
|
region: 'us-east-1',
|
|
service: 's3',
|
|
credentials: { accessKeyId: 'AKIA-FIXTURE', secretAccessKey: 'shhh' },
|
|
now: new Date('2026-05-09T12:00:00.000Z'),
|
|
});
|
|
// The authorization header must reference the four signed headers
|
|
// we expect for a no-body GET request with credentials.sessionToken
|
|
// absent.
|
|
expect(result.authorization).toContain('SignedHeaders=host;x-amz-content-sha256;x-amz-date');
|
|
expect(result.authorization).toMatch(/Signature=[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it('forwards the session token into a signed x-amz-security-token header', () => {
|
|
const headers: Record<string, string> = { 'host': 'b.s3.us-east-1.amazonaws.com' };
|
|
signSigV4({
|
|
method: 'PUT',
|
|
path: '/k',
|
|
query: '',
|
|
headers,
|
|
body: Buffer.from('hi'),
|
|
region: 'us-east-1',
|
|
service: 's3',
|
|
credentials: {
|
|
accessKeyId: 'AKIA-X',
|
|
secretAccessKey: 'sk',
|
|
sessionToken: 'TOK',
|
|
},
|
|
now: new Date('2026-05-09T00:00:00Z'),
|
|
});
|
|
expect(headers['x-amz-security-token']).toBe('TOK');
|
|
expect(headers['authorization']).toMatch(/SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token/);
|
|
});
|
|
});
|
|
|
|
describe('encodeS3PathSegment', () => {
|
|
it('encodes RFC-3986-reserved chars while preserving unreserved ones', () => {
|
|
expect(encodeS3PathSegment('hello.txt')).toBe('hello.txt');
|
|
expect(encodeS3PathSegment('a/b')).toBe('a%2Fb');
|
|
expect(encodeS3PathSegment("foo'bar")).toBe('foo%27bar');
|
|
expect(encodeS3PathSegment('a b c')).toBe('a%20b%20c');
|
|
});
|
|
});
|