@pulse-compute/s3 0.0.0 → 1.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -2
- package/as/read.as.ts +115 -0
- package/conformance/README.md +13 -0
- package/conformance/read.json +101 -0
- package/package.json +47 -6
- package/pulse.package.json +76 -0
- package/pulsewasm.compiler.cjs +96 -0
- package/pulsewasm.manifest.cjs +20 -0
- package/pulsewasm.native.cjs +11 -0
- package/src/index.d.ts +51 -0
- package/src/index.js +10 -0
- package/src/provider.js +136 -0
package/README.md
CHANGED
|
@@ -1,5 +1,83 @@
|
|
|
1
1
|
# @pulse-compute/s3
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<!-- pulse-package-status:start -->
|
|
4
|
+
> **Support tier:** Supported provider/extension surface<br>
|
|
5
|
+
> **Audience:** Applications using bounded exact-key object reads and writes through Pulse effects.<br>
|
|
6
|
+
> **Install directly:** Yes, when an application uses S3 object operations.<br>
|
|
7
|
+
> **Supported entry points:** `@pulse-compute/s3`<br>
|
|
8
|
+
> **Stability:** The package root supports head, getText and putText on Node Native, Node JavaScript and Fastly Native. Fastly JavaScript is ineligible. Provider and lowering subpaths are toolchain-only; live origin acceptance is separate.<br>
|
|
9
|
+
> **npm:** [`@pulse-compute/s3`](https://www.npmjs.com/package/@pulse-compute/s3)<br>
|
|
10
|
+
> **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.2/packages/s3/)
|
|
11
|
+
>
|
|
12
|
+
> This release-status block is generated from the synchronized `Pulse 1.0.0-beta.2` package policy.
|
|
13
|
+
<!-- pulse-package-status:end -->
|
|
4
14
|
|
|
5
|
-
This
|
|
15
|
+
This package provides bounded, exact-key `head`, `getText` and `putText` effects for
|
|
16
|
+
Node Native, Node JavaScript and Fastly Native. Await an operation into a local
|
|
17
|
+
variable or use it directly in keyed `ctx.parallel`. The binding is a literal
|
|
18
|
+
logical name; key and text may be runtime strings.
|
|
19
|
+
|
|
20
|
+
Providers own fixed HTTPS origin, bucket, region, named credential references,
|
|
21
|
+
deadline and transport. Fastly additionally requires a named static backend.
|
|
22
|
+
Crypto selection must include `SHA-256` and `HMAC-SHA256`. Crypto owns the
|
|
23
|
+
digest and MAC primitives; S3 owns signing and exact object semantics.
|
|
24
|
+
|
|
25
|
+
Put mappings under `<profile>.node.bindings.s3` or
|
|
26
|
+
`<profile>.fastly.bindings.s3`. A logical `objects` entry has these fields:
|
|
27
|
+
|
|
28
|
+
| Field | Value |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| `endpoint` | Fixed HTTPS origin, for example `https://objects.example.invalid` |
|
|
31
|
+
| `bucket`, `region` | Explicit bucket and signing region |
|
|
32
|
+
| `accessKeyIdSecret`, `secretAccessKeySecret` | Named provider secret references |
|
|
33
|
+
| `sessionTokenSecret` | Optional named session-token reference |
|
|
34
|
+
| `maxTextBytes` | 1–32768; default 32768 |
|
|
35
|
+
| `timeoutMs` | 1–30000; default 10000 |
|
|
36
|
+
| `backend` | Fastly-only named static backend |
|
|
37
|
+
|
|
38
|
+
The profile declares `crypto: ['SHA-256', 'HMAC-SHA256']`. A read looks like
|
|
39
|
+
`const object = await s3.getText(ctx, 'objects', key)` inside a handler.
|
|
40
|
+
|
|
41
|
+
GET accepts at most 32,768 raw bytes and strictly decodes UTF-8, preserving a BOM.
|
|
42
|
+
It returns the raw-byte SHA-256 digest and byte length with optional opaque ETag
|
|
43
|
+
and content type. HEAD requires a safe integer Content-Length but does not read a
|
|
44
|
+
body. A 404 returns `not-found`; other failures return a bounded reason and an
|
|
45
|
+
HTTP status when observed. Redirects, compression, retries and caching are disabled.
|
|
46
|
+
|
|
47
|
+
PUT accepts scalar text encoded once as UTF-8, including empty text, up to the
|
|
48
|
+
binding's byte limit. Use `const result = await s3.putText(ctx, 'objects', key,
|
|
49
|
+
text, { contentType: 'application/json' })`. Options are literal metadata; they
|
|
50
|
+
do not serialize text. The default content type is `text/plain; charset=utf-8`.
|
|
51
|
+
A complete 200 acknowledgement with an empty bounded body returns `stored`,
|
|
52
|
+
sent byte length, SHA-256 and optional opaque ETag. The digest identifies sent
|
|
53
|
+
bytes; it is not an origin durability receipt.
|
|
54
|
+
|
|
55
|
+
Before dispatch, validation, credential, signing and deadline failures return
|
|
56
|
+
`not-stored`. A complete explicit 4xx rejection, except 408, also returns
|
|
57
|
+
`not-stored`. After entering send, transport loss, timeout, unavailable origin
|
|
58
|
+
or malformed acknowledgement returns `unknown`: bytes may already be stored.
|
|
59
|
+
There are no retries. Cancellation terminates the operation through the existing
|
|
60
|
+
request lifecycle; it does not return an S3 outcome or promise rollback. Fastly
|
|
61
|
+
has no pending-request cancel hostcall; invocation termination owns its release.
|
|
62
|
+
|
|
63
|
+
Node JavaScript explicitly uses Crypto's bounded Web Crypto byte realization.
|
|
64
|
+
Native targets compose Crypto's AssemblyScript primitives. Fastly JavaScript S3
|
|
65
|
+
is ineligible because its SDK projects raw headers and loses required metadata
|
|
66
|
+
information. This target exclusion does not gate supported targets.
|
|
67
|
+
|
|
68
|
+
For a JSON request carrying the maximum text consisting of escaped control
|
|
69
|
+
characters, configure the existing `schemas.maxBytes` request envelope to
|
|
70
|
+
262144; the object itself remains bounded to 32768 UTF-8 bytes. The acceptance
|
|
71
|
+
consumer uses generic JSON responses (`strict: false`) to compare the complete
|
|
72
|
+
result unions. Applications using strict JSON must declare response schemas.
|
|
73
|
+
|
|
74
|
+
The package root is the supported application API in the synchronized
|
|
75
|
+
`1.0.0-beta.2` release candidate. Provider, manifest, compiler and Native
|
|
76
|
+
subpaths are trusted toolchain integration, not application imports. Install
|
|
77
|
+
S3 with the exact compatible Pulse, CLI and provider candidate set; candidate
|
|
78
|
+
preparation does not imply npm publication.
|
|
79
|
+
|
|
80
|
+
See the [S3 guide](https://pulsecompute.io/v1.0.0-beta.2/packages/s3/) for the
|
|
81
|
+
lowering contract, support matrix and acceptance boundaries. Local acceptance
|
|
82
|
+
executes Node Native, Node JavaScript and compiled Fastly Native Wasm against
|
|
83
|
+
controlled origins. Live Object Storage evidence follows infrastructure setup.
|
package/as/read.as.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// S3 owns exact-key encoding, SigV4 composition and strict byte interpretation.
|
|
2
|
+
// Crypto supplies the selected digest/MAC exports. Providers supply credentials,
|
|
3
|
+
// time and transport; none of those authorities are application imports.
|
|
4
|
+
function __pulse_s3_bytes(text: string): Uint8Array { return Uint8Array.wrap(String.UTF8.encode(text, false)) }
|
|
5
|
+
function __pulse_s3_scalar(text: string, controls: bool = false): bool {
|
|
6
|
+
for (let i = 0; i < text.length; i++) {
|
|
7
|
+
const c = text.charCodeAt(i);
|
|
8
|
+
if (controls && (c < 32 || (c >= 127 && c <= 159))) return false;
|
|
9
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
10
|
+
if (++i >= text.length) return false;
|
|
11
|
+
const low = text.charCodeAt(i);
|
|
12
|
+
if (low < 0xdc00 || low > 0xdfff) return false;
|
|
13
|
+
} else if (c >= 0xdc00 && c <= 0xdfff) return false;
|
|
14
|
+
}
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
function __pulse_s3_key(key: string): string | null {
|
|
18
|
+
if (!key.length || !__pulse_s3_scalar(key, true)) return null;
|
|
19
|
+
const segments = key.split('/');
|
|
20
|
+
for (let i = 0; i < segments.length; i++) if (segments[i] == '.' || segments[i] == '..') return null;
|
|
21
|
+
const bytes = __pulse_s3_bytes(key);
|
|
22
|
+
if (bytes.length > 1024) return null;
|
|
23
|
+
const alphabet = '0123456789ABCDEF'; let output = '';
|
|
24
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
25
|
+
const b = bytes[i];
|
|
26
|
+
if ((b >= 65 && b <= 90) || (b >= 97 && b <= 122) || (b >= 48 && b <= 57) || b == 45 || b == 46 || b == 95 || b == 126 || b == 47) output += String.fromCharCode(b);
|
|
27
|
+
else output += '%' + alphabet.charAt(b >> 4) + alphabet.charAt(b & 15);
|
|
28
|
+
}
|
|
29
|
+
return output;
|
|
30
|
+
}
|
|
31
|
+
function __pulse_s3_utf8_valid(bytes: Uint8Array): bool {
|
|
32
|
+
for (let i = 0; i < bytes.length;) {
|
|
33
|
+
const lead = bytes[i++]; if (lead < 128) continue;
|
|
34
|
+
let count = 0, minimum = 128, maximum = 191;
|
|
35
|
+
if (lead >= 194 && lead <= 223) count = 1;
|
|
36
|
+
else if (lead >= 224 && lead <= 239) { count = 2; if (lead == 224) minimum = 160; if (lead == 237) maximum = 159; }
|
|
37
|
+
else if (lead >= 240 && lead <= 244) { count = 3; if (lead == 240) minimum = 144; if (lead == 244) maximum = 143; }
|
|
38
|
+
else return false;
|
|
39
|
+
if (i + count > bytes.length || i32(bytes[i]) < minimum || i32(bytes[i]) > maximum) return false;
|
|
40
|
+
i++; for (let n = 1; n < count; n++, i++) if (bytes[i] < 128 || bytes[i] > 191) return false;
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
function __pulse_s3_hex(bytes: Uint8Array): string {
|
|
45
|
+
const alphabet = '0123456789abcdef'; let output = '';
|
|
46
|
+
for (let i = 0; i < bytes.length; i++) output += alphabet.charAt(bytes[i] >> 4) + alphabet.charAt(bytes[i] & 15);
|
|
47
|
+
return output;
|
|
48
|
+
}
|
|
49
|
+
function __pulse_s3_sha(bytes: Uint8Array): Uint8Array {
|
|
50
|
+
const output = new Uint8Array(32);
|
|
51
|
+
if (pulse_crypto_sha256_bytes_v1(i32(bytes.dataStart), bytes.length, i32(output.dataStart), 32) != 1) unreachable();
|
|
52
|
+
return output;
|
|
53
|
+
}
|
|
54
|
+
function __pulse_s3_mac(key: Uint8Array, text: string): Uint8Array {
|
|
55
|
+
const data = __pulse_s3_bytes(text), output = new Uint8Array(32);
|
|
56
|
+
const status = pulse_crypto_hmac_sha256_bytes_v1(i32(key.dataStart), key.length, i32(data.dataStart), data.length, i32(output.dataStart), 32);
|
|
57
|
+
key.fill(0);
|
|
58
|
+
if (status != 1) unreachable();
|
|
59
|
+
return output;
|
|
60
|
+
}
|
|
61
|
+
function __pulse_s3_ascii(text: string, max: i32): bool {
|
|
62
|
+
if (!text.length || text.length > max) return false;
|
|
63
|
+
for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) < 33 || text.charCodeAt(i) > 126) return false;
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
function __pulse_s3_credentials(id: string, secret: string, token: string): bool {
|
|
67
|
+
return __pulse_s3_ascii(id, 256) && __pulse_s3_scalar(secret) && secret.length > 0 && __pulse_s3_bytes(secret).length <= 4096
|
|
68
|
+
&& secret.indexOf('\r') < 0 && secret.indexOf('\n') < 0 && (!token.length || __pulse_s3_ascii(token, 4096));
|
|
69
|
+
}
|
|
70
|
+
function __pulse_s3_pad(value: i32, digits: i32): string { return value.toString().padStart(digits, '0') }
|
|
71
|
+
function __pulse_s3_date(seconds: i64): string {
|
|
72
|
+
if (seconds < 0 || seconds > 253402300799) return '';
|
|
73
|
+
// Gregorian civil date from Unix days, bounded to years 1970..9999.
|
|
74
|
+
const z = i32(seconds / 86400) + 719468, era = z / 146097, doe = z - era * 146097;
|
|
75
|
+
const yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
|
76
|
+
let year = yoe + era * 400;
|
|
77
|
+
const doy = doe - (365 * yoe + yoe / 4 - yoe / 100), mp = (5 * doy + 2) / 153;
|
|
78
|
+
const day = doy - (153 * mp + 2) / 5 + 1, month = mp + (mp < 10 ? 3 : -9);
|
|
79
|
+
if (month <= 2) year++;
|
|
80
|
+
return __pulse_s3_pad(year, 4) + __pulse_s3_pad(month, 2) + __pulse_s3_pad(day, 2) + 'T'
|
|
81
|
+
+ __pulse_s3_pad(i32(seconds / 3600 % 24), 2) + __pulse_s3_pad(i32(seconds / 60 % 60), 2) + __pulse_s3_pad(i32(seconds % 60), 2) + 'Z';
|
|
82
|
+
}
|
|
83
|
+
class __PulseS3Signature {
|
|
84
|
+
date: string = ''; digest: string = ''; authorization: string = '';
|
|
85
|
+
}
|
|
86
|
+
function __pulse_s3_sign(method: string, path: string, host: string, region: string, id: string, secret: string, token: string, seconds: i64, body: Uint8Array, contentType: string): __PulseS3Signature {
|
|
87
|
+
const result = new __PulseS3Signature(); result.date = __pulse_s3_date(seconds);
|
|
88
|
+
result.digest = __pulse_s3_hex(__pulse_s3_sha(body));
|
|
89
|
+
const names = (contentType.length ? 'content-type;' : '') + 'host;x-amz-content-sha256;x-amz-date' + (token.length ? ';x-amz-security-token' : '');
|
|
90
|
+
const headers = (contentType.length ? 'content-type:' + __pulse_s3_header_spaces(contentType) + '\n' : '') + 'host:' + host + '\nx-amz-content-sha256:' + result.digest + '\nx-amz-date:' + result.date + '\n'
|
|
91
|
+
+ (token.length ? 'x-amz-security-token:' + token + '\n' : '');
|
|
92
|
+
const canonical = method + '\n' + path + '\n\n' + headers + '\n' + names + '\n' + result.digest;
|
|
93
|
+
const day = result.date.slice(0, 8), scope = day + '/' + region + '/s3/aws4_request';
|
|
94
|
+
const toSign = 'AWS4-HMAC-SHA256\n' + result.date + '\n' + scope + '\n' + __pulse_s3_hex(__pulse_s3_sha(__pulse_s3_bytes(canonical)));
|
|
95
|
+
let key = __pulse_s3_mac(__pulse_s3_bytes('AWS4' + secret), day);
|
|
96
|
+
key = __pulse_s3_mac(key, region); key = __pulse_s3_mac(key, 's3'); key = __pulse_s3_mac(key, 'aws4_request');
|
|
97
|
+
const signature = __pulse_s3_mac(key, toSign);
|
|
98
|
+
result.authorization = 'AWS4-HMAC-SHA256 Credential=' + id + '/' + scope + ', SignedHeaders=' + names + ', Signature=' + __pulse_s3_hex(signature);
|
|
99
|
+
signature.fill(0); return result;
|
|
100
|
+
}
|
|
101
|
+
function __pulse_s3_status_reason(status: i32): string {
|
|
102
|
+
if (status == 401 || status == 403) return 'not-authorized';
|
|
103
|
+
if (status == 429) return 'throttled'; if (status == 408) return 'timeout';
|
|
104
|
+
if (status >= 500 && status <= 599) return 'unavailable'; return 'protocol';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function __pulse_s3_header_spaces(text: string): string {
|
|
108
|
+
let output = '', space = false;
|
|
109
|
+
for (let i = 0; i < text.length; i++) {
|
|
110
|
+
const c = text.charAt(i);
|
|
111
|
+
if (c != ' ' || !space) output += c;
|
|
112
|
+
space = c == ' ';
|
|
113
|
+
}
|
|
114
|
+
return output;
|
|
115
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# S3 conformance
|
|
2
|
+
|
|
3
|
+
`read.json` contains exact key-encoding and raw-body vectors. The package
|
|
4
|
+
contract records all three operations: `head`, `getText` and `putText`.
|
|
5
|
+
|
|
6
|
+
Repository acceptance runs the same read and write failure corpus against
|
|
7
|
+
Node Native, Node JavaScript and Fastly Native, then repeats it with the exact
|
|
8
|
+
installed release tarballs. It checks independent SigV4 signatures, UTF-8,
|
|
9
|
+
metadata limits, truncation, deadlines, write uncertainty and secret redaction.
|
|
10
|
+
Fastly Native executes compiled Wasm against host ABI fixtures. These checks
|
|
11
|
+
do not establish live origin behavior; that evidence follows infrastructure
|
|
12
|
+
setup. Fastly JavaScript is ineligible for S3 because its SDK loses raw header
|
|
13
|
+
metadata. See the [S3 guide](https://pulsecompute.io/v1.0.0-beta.2/packages/s3/).
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
{
|
|
2
|
+
"keys": [
|
|
3
|
+
{
|
|
4
|
+
"key": "plain",
|
|
5
|
+
"encoded": "plain"
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"key": "/a//b/",
|
|
9
|
+
"encoded": "/a//b/"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"key": "%2F",
|
|
13
|
+
"encoded": "%252F"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"key": "a b?#",
|
|
17
|
+
"encoded": "a%20b%3F%23"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"key": "!'()*",
|
|
21
|
+
"encoded": "%21%27%28%29%2A"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"key": "caf\u00e9/\u96ea",
|
|
25
|
+
"encoded": "caf%C3%A9/%E9%9B%AA"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"key": "cafe\u0301",
|
|
29
|
+
"encoded": "cafe%CC%81"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"key": "a\\b",
|
|
33
|
+
"encoded": "a%5Cb"
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"invalidKeys": [
|
|
37
|
+
"",
|
|
38
|
+
".",
|
|
39
|
+
"..",
|
|
40
|
+
"a/../b",
|
|
41
|
+
"./a",
|
|
42
|
+
"a/.",
|
|
43
|
+
"\u0000",
|
|
44
|
+
"\u007f",
|
|
45
|
+
"\u0085",
|
|
46
|
+
"\ud800",
|
|
47
|
+
"\udfff"
|
|
48
|
+
],
|
|
49
|
+
"bodies": [
|
|
50
|
+
{
|
|
51
|
+
"id": "empty",
|
|
52
|
+
"hex": "",
|
|
53
|
+
"text": "",
|
|
54
|
+
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": "json",
|
|
58
|
+
"hex": "7b2261223a317d0a",
|
|
59
|
+
"text": "{\"a\":1}\n",
|
|
60
|
+
"sha256": "e346432021b04179518d9614f3560ccd71354a4ee101ddcb893d6959a9d6301c"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"id": "unicode",
|
|
64
|
+
"hex": "636166c3a92fe99baa",
|
|
65
|
+
"text": "caf\u00e9/\u96ea",
|
|
66
|
+
"sha256": "3e5e62d27765a8a7bcb9866e733e86ee7c001fd768d8b0a3a7beaa60afd90ade"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"id": "bom",
|
|
70
|
+
"hex": "efbbbf68656c6c6f",
|
|
71
|
+
"text": "\ufeffhello",
|
|
72
|
+
"sha256": "7489ebbcc2a00056ddaaaac190bce473e5c03696ea1bd8ed83cf59a174283862"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "nul",
|
|
76
|
+
"hex": "00",
|
|
77
|
+
"text": "\u0000",
|
|
78
|
+
"sha256": "6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d"
|
|
79
|
+
}
|
|
80
|
+
],
|
|
81
|
+
"invalidUtf8": [
|
|
82
|
+
"c080",
|
|
83
|
+
"eda080",
|
|
84
|
+
"f4908080",
|
|
85
|
+
"e282",
|
|
86
|
+
"80",
|
|
87
|
+
"ff"
|
|
88
|
+
],
|
|
89
|
+
"signing": {
|
|
90
|
+
"source": "https://docs.aws.amazon.com/AmazonS3/latest/developerguide/sig-v4-header-based-auth.html",
|
|
91
|
+
"date": "20130524T000000Z",
|
|
92
|
+
"region": "us-east-1",
|
|
93
|
+
"host": "examplebucket.s3.amazonaws.com",
|
|
94
|
+
"path": "/test.txt",
|
|
95
|
+
"range": "bytes=0-9",
|
|
96
|
+
"accessKeyId": "AKIAIOSFODNN7EXAMPLE",
|
|
97
|
+
"secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
|
98
|
+
"canonicalRequestSha256": "7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972",
|
|
99
|
+
"signature": "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
|
|
100
|
+
}
|
|
101
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pulse-compute/s3",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Inert bootstrap placeholder for @pulse-compute/s3.",
|
|
3
|
+
"version": "1.0.0-beta.2",
|
|
5
4
|
"license": "Apache-2.0",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": "^22.14.0 || ^24.0.0"
|
|
7
|
+
},
|
|
8
|
+
"description": "Bounded exact-key S3 reads and writes through Pulse effects.",
|
|
9
|
+
"type": "commonjs",
|
|
10
|
+
"main": "./src/index.js",
|
|
11
|
+
"types": "./src/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./src/index.d.ts",
|
|
15
|
+
"require": "./src/index.js",
|
|
16
|
+
"default": "./src/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./provider": "./src/provider.js",
|
|
19
|
+
"./pulsewasm/manifest": "./pulsewasm.manifest.cjs",
|
|
20
|
+
"./pulsewasm/compiler": "./pulsewasm.compiler.cjs",
|
|
21
|
+
"./pulsewasm-native": "./pulsewasm.native.cjs"
|
|
22
|
+
},
|
|
6
23
|
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"as",
|
|
26
|
+
"conformance",
|
|
27
|
+
"pulse.package.json",
|
|
28
|
+
"pulsewasm.manifest.cjs",
|
|
29
|
+
"pulsewasm.compiler.cjs",
|
|
30
|
+
"pulsewasm.native.cjs",
|
|
7
31
|
"README.md",
|
|
8
32
|
"LICENSE",
|
|
9
33
|
"NOTICE"
|
|
10
34
|
],
|
|
35
|
+
"pulse": {
|
|
36
|
+
"contract": "./pulse.package.json"
|
|
37
|
+
},
|
|
38
|
+
"pulsewasm": {
|
|
39
|
+
"manifest": "./pulsewasm.manifest.cjs"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@pulse-compute/runtime": "1.0.0-beta.2",
|
|
43
|
+
"@pulse-compute/wasm-contracts": "1.0.0-beta.2"
|
|
44
|
+
},
|
|
11
45
|
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
|
-
|
|
14
|
-
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/pulse-compute/pulse.git",
|
|
51
|
+
"directory": "packages/s3"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://pulsecompute.io/v1.0.0-beta.2/packages/s3/",
|
|
54
|
+
"bugs": {
|
|
55
|
+
"url": "https://github.com/pulse-compute/pulse/issues"
|
|
15
56
|
}
|
|
16
|
-
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "pulse.package-contract.v1",
|
|
3
|
+
"kind": "pulse.package-contract",
|
|
4
|
+
"contractId": "pulse.s3",
|
|
5
|
+
"npmPackage": "@pulse-compute/s3",
|
|
6
|
+
"description": "Bounded exact-key S3 reads and writes.",
|
|
7
|
+
"authoring": {
|
|
8
|
+
"namespace": "s3",
|
|
9
|
+
"import": "@pulse-compute/s3",
|
|
10
|
+
"symbols": [
|
|
11
|
+
"head",
|
|
12
|
+
"getText",
|
|
13
|
+
"putText"
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
"ownership": {
|
|
17
|
+
"profileFragment": "s3",
|
|
18
|
+
"helperImports": [],
|
|
19
|
+
"reExportImports": []
|
|
20
|
+
},
|
|
21
|
+
"composition": {
|
|
22
|
+
"status": "reserved",
|
|
23
|
+
"helperSymbol": "s3",
|
|
24
|
+
"profileToken": "opaque-direct-use",
|
|
25
|
+
"reasonCode": "PULSE_PACKAGE_COMPOSITION_HELPER_NOT_REALIZED"
|
|
26
|
+
},
|
|
27
|
+
"targets": {
|
|
28
|
+
"native": {
|
|
29
|
+
"status": "provider-dependent",
|
|
30
|
+
"realization": "canonical-package-lowering",
|
|
31
|
+
"lowerableSubpath": "@pulse-compute/s3",
|
|
32
|
+
"providerRequirements": [
|
|
33
|
+
"s3.head",
|
|
34
|
+
"s3.getText",
|
|
35
|
+
"s3.putText",
|
|
36
|
+
"secret.get",
|
|
37
|
+
"time.wall-clock"
|
|
38
|
+
],
|
|
39
|
+
"reasonCode": "PULSE_PACKAGE_PROVIDER_CAPABILITY_REQUIRED"
|
|
40
|
+
},
|
|
41
|
+
"javascript": {
|
|
42
|
+
"status": "supported",
|
|
43
|
+
"realization": "javascript-package-runtime",
|
|
44
|
+
"entry": "./",
|
|
45
|
+
"providerRequirements": [
|
|
46
|
+
"s3.head",
|
|
47
|
+
"s3.getText",
|
|
48
|
+
"s3.putText",
|
|
49
|
+
"secret.get",
|
|
50
|
+
"time.wall-clock"
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"lowering": {
|
|
55
|
+
"manifest": "./pulsewasm.manifest.cjs",
|
|
56
|
+
"trust": "first-party",
|
|
57
|
+
"canonicalOperationVersion": "pulse.canonical-package-operation.v1",
|
|
58
|
+
"bundleVersion": "pulse.package-lowering-bundle.v1",
|
|
59
|
+
"providerRequirementVersion": "pulse.provider-requirement-record.v1"
|
|
60
|
+
},
|
|
61
|
+
"conformance": {
|
|
62
|
+
"status": "target-overlap",
|
|
63
|
+
"fixtureRoots": [
|
|
64
|
+
"./conformance"
|
|
65
|
+
],
|
|
66
|
+
"semanticCases": [
|
|
67
|
+
"s3.head",
|
|
68
|
+
"s3.getText",
|
|
69
|
+
"s3.putText"
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
"docs": {
|
|
73
|
+
"readme": "./README.md",
|
|
74
|
+
"contract": "./pulse.package.json"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { normalizeDiagnostic, sourceLoc } = require('@pulse-compute/wasm-contracts/diagnostics');
|
|
3
|
+
const { CANONICAL_PACKAGE_EFFECT_VERSION } = require('@pulse-compute/wasm-contracts/handler/canonical-runtime');
|
|
4
|
+
const { PACKAGE_CRYPTO_REQUIREMENT_VERSION } = require('@pulse-compute/wasm-contracts/package/package-contract');
|
|
5
|
+
const { S3_PACKAGE, S3_CONTRACT_ID, S3_OPERATIONS, S3_LOWERING_PLAN_VERSION, S3_CRYPTO_ALGORITHMS } = require('@pulse-compute/wasm-contracts/s3/contracts');
|
|
6
|
+
const { normalizePutOptions } = require('./src/provider.js');
|
|
7
|
+
|
|
8
|
+
function buildS3LoweringPlan(input = {}) {
|
|
9
|
+
const ts = input.typescript || require('typescript');
|
|
10
|
+
const source = input.sourceFile || ts.createSourceFile(input.sourcePath || 'index.ts', input.sourceText || '', ts.ScriptTarget.ES2022, true);
|
|
11
|
+
const namespaces = new Set();
|
|
12
|
+
const diagnostics = [];
|
|
13
|
+
const effects = [];
|
|
14
|
+
const error = (node, suffix, message) => diagnostics.push(normalizeDiagnostic({
|
|
15
|
+
phase: 's3-lowering', severity: 'error', code: `PULSEWASM_S3_${suffix}`, message,
|
|
16
|
+
hint: 'Use a supported s3 call with literal binding/options and runtime key/text in an awaited handler position.', loc: sourceLoc(source, node)
|
|
17
|
+
}));
|
|
18
|
+
for (const statement of source.statements) {
|
|
19
|
+
if (!ts.isImportDeclaration(statement) || statement.moduleSpecifier.text !== S3_PACKAGE) continue;
|
|
20
|
+
const named = statement.importClause && statement.importClause.namedBindings;
|
|
21
|
+
if (!named || !ts.isNamedImports(named)) { error(statement, 'IMPORT_UNSUPPORTED', 'Import the named s3 facade.'); continue; }
|
|
22
|
+
for (const entry of named.elements) {
|
|
23
|
+
if ((entry.propertyName || entry.name).text !== 's3') error(entry, 'IMPORT_UNSUPPORTED', 'Only the s3 facade is lowerable.');
|
|
24
|
+
else namespaces.add(entry.name.text);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function visit(node) {
|
|
28
|
+
if (ts.isIdentifier(node) && namespaces.has(node.text) && !ts.isImportSpecifier(node.parent)) {
|
|
29
|
+
const access = node.parent;
|
|
30
|
+
if (!ts.isPropertyAccessExpression(access) || access.expression !== node || !ts.isCallExpression(access.parent) || access.parent.expression !== access) {
|
|
31
|
+
error(node, 'FACADE_UNSUPPORTED', 'S3 facade methods cannot be detached or aliased.');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)
|
|
35
|
+
&& ts.isIdentifier(node.expression.expression) && namespaces.has(node.expression.expression.text)) {
|
|
36
|
+
const method = node.expression.name.text;
|
|
37
|
+
const op = S3_OPERATIONS[method];
|
|
38
|
+
if (!op) { error(node, 'OPERATION_UNSUPPORTED', 'S3 supports head, getText and putText.'); return; }
|
|
39
|
+
const put = method === 'putText';
|
|
40
|
+
let fn = node.parent;
|
|
41
|
+
while (fn && !ts.isArrowFunction(fn) && !ts.isFunctionExpression(fn)) fn = fn.parent;
|
|
42
|
+
const ctx = fn && fn.parameters[0] && fn.parameters[0].name;
|
|
43
|
+
if (!(put ? [4, 5].includes(node.arguments.length) : node.arguments.length === 3) || !ctx || !ts.isIdentifier(ctx)
|
|
44
|
+
|| !ts.isIdentifier(node.arguments[0]) || node.arguments[0].text !== ctx.text) {
|
|
45
|
+
error(node, 'ARGUMENTS_UNSUPPORTED', 'S3 requires current handler context, literal binding, runtime key and, for PUT, text with optional literal options.'); return;
|
|
46
|
+
}
|
|
47
|
+
const binding = node.arguments[1];
|
|
48
|
+
if (!ts.isStringLiteralLike(binding) || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(binding.text)) {
|
|
49
|
+
error(binding, 'BINDING_UNSUPPORTED', 'S3 binding must be a valid literal logical name.'); return;
|
|
50
|
+
}
|
|
51
|
+
let options = {};
|
|
52
|
+
if (put) {
|
|
53
|
+
const nodeOptions = node.arguments[4];
|
|
54
|
+
try {
|
|
55
|
+
if (nodeOptions) {
|
|
56
|
+
if (!ts.isObjectLiteralExpression(nodeOptions) || nodeOptions.properties.length > 1) throw new TypeError();
|
|
57
|
+
for (const property of nodeOptions.properties) {
|
|
58
|
+
if (!ts.isPropertyAssignment(property) || !ts.isIdentifier(property.name) && !ts.isStringLiteralLike(property.name)
|
|
59
|
+
|| property.name.text !== 'contentType' || !ts.isStringLiteralLike(property.initializer)) throw new TypeError();
|
|
60
|
+
options.contentType = property.initializer.text;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
options = normalizePutOptions(options);
|
|
64
|
+
} catch { error(nodeOptions || node, 'OPTIONS_UNSUPPORTED', 'PUT options must be a literal object containing only a valid literal contentType.'); return; }
|
|
65
|
+
}
|
|
66
|
+
let placement = '';
|
|
67
|
+
const parent = node.parent;
|
|
68
|
+
if (ts.isAwaitExpression(parent) && ts.isVariableDeclaration(parent.parent)) placement = 'variable';
|
|
69
|
+
if (ts.isPropertyAssignment(parent) && ts.isObjectLiteralExpression(parent.parent)) {
|
|
70
|
+
const call = parent.parent.parent;
|
|
71
|
+
if (ts.isCallExpression(call) && ts.isPropertyAccessExpression(call.expression)
|
|
72
|
+
&& ts.isIdentifier(call.expression.expression) && call.expression.expression.text === ctx.text
|
|
73
|
+
&& call.expression.name.text === 'parallel' && ts.isAwaitExpression(call.parent)) placement = 'statement';
|
|
74
|
+
}
|
|
75
|
+
if (!placement) { error(node, 'PLACEMENT_UNSUPPORTED', 'Await S3 into a local variable or a direct keyed ctx.parallel member.'); return; }
|
|
76
|
+
effects.push({
|
|
77
|
+
version: CANONICAL_PACKAGE_EFFECT_VERSION, contractId: S3_CONTRACT_ID, package: S3_PACKAGE, import: S3_PACKAGE,
|
|
78
|
+
...op, providerKind: 's3', operation: method, placement,
|
|
79
|
+
range: { start: node.getStart(source), end: node.getEnd() },
|
|
80
|
+
resource: { binding: binding.text }, payload: { binding: binding.text, ...options },
|
|
81
|
+
runtimeInputs: [{ name: 'key', argumentIndex: 2, source: 'package-call-argument' }, ...(put ? [{ name: 'text', argumentIndex: 3, source: 'package-call-argument' }] : [])],
|
|
82
|
+
providerRequirements: [op.kind, 'secret.get', 'time.wall-clock'], schemaReferences: [],
|
|
83
|
+
redaction: ['key', 'text', 'sha256', 'etag', 'contentType'], loc: sourceLoc(source, node)
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
ts.forEachChild(node, visit);
|
|
87
|
+
}
|
|
88
|
+
visit(source);
|
|
89
|
+
const cryptoRequirements = effects.length ? [{ version: PACKAGE_CRYPTO_REQUIREMENT_VERSION, algorithms: S3_CRYPTO_ALGORITHMS, reachable: true, requestedBy: S3_PACKAGE, semanticOwner: '@pulse-compute/crypto' }] : [];
|
|
90
|
+
return {
|
|
91
|
+
artifact: { version: S3_LOWERING_PLAN_VERSION, contractId: S3_CONTRACT_ID, package: S3_PACKAGE, lowerableSubpath: S3_PACKAGE,
|
|
92
|
+
status: diagnostics.length ? 'error' : 'ok', canonicalEffects: effects, cryptoRequirements },
|
|
93
|
+
canonicalEffects: effects, cryptoRequirements, diagnostics, hasErrors: diagnostics.length > 0
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
module.exports = { buildS3LoweringPlan };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { S3_PACKAGE, S3_CONTRACT_ID, S3_OPERATIONS } = require('@pulse-compute/wasm-contracts/s3/contracts');
|
|
3
|
+
module.exports = Object.freeze({
|
|
4
|
+
version: 'pulsewasm.lowerable-library-manifest.v2', kind: 'pulsewasm.lowerable-library-manifest',
|
|
5
|
+
contractId: S3_CONTRACT_ID, npmPackage: S3_PACKAGE, lowerableSubpath: S3_PACKAGE,
|
|
6
|
+
facade: { namespace: 's3', import: S3_PACKAGE, symbols: ['head', 'getText', 'putText'] },
|
|
7
|
+
publicApi: { package: S3_PACKAGE, symbols: ['s3'], compatibilitySubpaths: [], loweringIntentionallyNarrow: true },
|
|
8
|
+
compiler: { version: 'pulsewasm.lowerable-compiler-builder.v1', entry: './pulsewasm.compiler.cjs', export: 'buildS3LoweringPlan', artifact: 's3-lowering-plan.json', builderOwner: S3_PACKAGE, trust: 'first-party', inputs: ['typescript.SourceFile'] },
|
|
9
|
+
modes: {
|
|
10
|
+
typescript: { entry: './src/index.js' }, jsEngine: { entry: './src/index.js', behavior: 'provider-dependent' },
|
|
11
|
+
wasm: {
|
|
12
|
+
mode: 'wasm-sidecar', sidecar: './as/read.as.ts',
|
|
13
|
+
// The builder emits canonical effects. Native realizations
|
|
14
|
+
// compose the shared package-owned signing protocol, not a direct call.
|
|
15
|
+
lowerings: Object.keys(S3_OPERATIONS).map((name) => ({ tsSymbol: `s3.${name}`, asSymbol: '__pulse_s3_sign', callShape: name === 'putText' ? 'context-literal-binding-runtime-key-text-literal-options' : 'context-literal-binding-runtime-key', hostCapabilities: ['backend-fetch', 'body', 'clock', 'result'] })),
|
|
16
|
+
hostCapabilities: ['backend-fetch', 'body', 'clock', 'result']
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
policy: { packageOwnsFacadeMapping: true, canonicalRootLowering: true, automaticFallback: false }
|
|
20
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
function pulseS3AssemblyScriptSource() {
|
|
6
|
+
const source = fs.readFileSync(path.join(__dirname, 'as/read.as.ts'), 'utf8');
|
|
7
|
+
return Object.freeze({ id: 'pulse-s3-read-as', owner: '@pulse-compute/s3', version: 'pulse.s3-native-read.v1',
|
|
8
|
+
source, sourceSha256: crypto.createHash('sha256').update(source).digest('hex'),
|
|
9
|
+
cryptoRequirements: Object.freeze(['SHA-256', 'HMAC-SHA256']) });
|
|
10
|
+
}
|
|
11
|
+
module.exports = { pulseS3AssemblyScriptSource };
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Bounded S3 contract. Provider/target eligibility remains explicit.
|
|
2
|
+
import type { PulseContext, PulseParallelEffect } from '@pulse-compute/runtime';
|
|
3
|
+
|
|
4
|
+
export type S3ReadFailureReason =
|
|
5
|
+
| 'invalid-key' | 'configuration' | 'credentials' | 'not-authorized'
|
|
6
|
+
| 'throttled' | 'unavailable' | 'transport' | 'timeout' | 'protocol'
|
|
7
|
+
| 'too-large' | 'invalid-utf8';
|
|
8
|
+
|
|
9
|
+
export interface S3Metadata {
|
|
10
|
+
readonly byteLength: number;
|
|
11
|
+
readonly etag?: string;
|
|
12
|
+
readonly contentType?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface S3ReadFailure {
|
|
16
|
+
readonly status: 'failed';
|
|
17
|
+
readonly reason: S3ReadFailureReason;
|
|
18
|
+
readonly httpStatus?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type S3HeadResult =
|
|
22
|
+
| ({ readonly status: 'found' } & S3Metadata)
|
|
23
|
+
| { readonly status: 'not-found' }
|
|
24
|
+
| S3ReadFailure;
|
|
25
|
+
|
|
26
|
+
export type S3GetTextResult =
|
|
27
|
+
| ({ readonly status: 'found'; readonly text: string; readonly sha256: string } & S3Metadata)
|
|
28
|
+
| { readonly status: 'not-found' }
|
|
29
|
+
| S3ReadFailure;
|
|
30
|
+
|
|
31
|
+
export type S3PutTextResult =
|
|
32
|
+
| { readonly status: 'stored'; readonly byteLength: number; readonly sha256: string; readonly etag?: string }
|
|
33
|
+
| { readonly status: 'not-stored'; readonly reason:
|
|
34
|
+
'invalid-key' | 'invalid-text' | 'too-large' | 'configuration' | 'credentials'
|
|
35
|
+
| 'not-authorized' | 'rejected' | 'throttled' | 'transport' | 'timeout';
|
|
36
|
+
readonly httpStatus?: number }
|
|
37
|
+
| { readonly status: 'unknown'; readonly reason: 'transport' | 'timeout' | 'unavailable' | 'protocol';
|
|
38
|
+
readonly httpStatus?: number };
|
|
39
|
+
|
|
40
|
+
export interface S3PutTextOptions {
|
|
41
|
+
readonly contentType?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Binding and options must be compiler-proven literals. Key and text may be
|
|
45
|
+
// runtime strings. TypeScript alone cannot enforce that lowering restriction.
|
|
46
|
+
export declare const s3: {
|
|
47
|
+
head(ctx: PulseContext, binding: string, key: string): PulseParallelEffect<S3HeadResult>;
|
|
48
|
+
getText(ctx: PulseContext, binding: string, key: string): PulseParallelEffect<S3GetTextResult>;
|
|
49
|
+
putText(ctx: PulseContext, binding: string, key: string, text: string,
|
|
50
|
+
options?: S3PutTextOptions): PulseParallelEffect<S3PutTextResult>;
|
|
51
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { createPackageRuntime } = require('@pulse-compute/runtime/package');
|
|
3
|
+
const { S3_PACKAGE, S3_CONTRACT_ID, S3_OPERATIONS } = require('@pulse-compute/wasm-contracts/s3/contracts');
|
|
4
|
+
const { normalizeResult, normalizePutOptions } = require('./provider.js');
|
|
5
|
+
const runtime = createPackageRuntime({ package: S3_PACKAGE, contractId: S3_CONTRACT_ID, providerKind: 's3', operations: S3_OPERATIONS });
|
|
6
|
+
exports.s3 = Object.freeze({
|
|
7
|
+
head(ctx, binding, key) { return runtime.effect(ctx, 'head', { binding, key }, (result) => normalizeResult('head', result)); },
|
|
8
|
+
getText(ctx, binding, key) { return runtime.effect(ctx, 'getText', { binding, key }, (result) => normalizeResult('getText', result)); },
|
|
9
|
+
putText(ctx, binding, key, text, options) { return runtime.effect(ctx, 'putText', { binding, key, text, ...normalizePutOptions(options) }, (result) => normalizeResult('putText', result)); }
|
|
10
|
+
});
|
package/src/provider.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Trusted provider surface; never an application lowering escape. Protocol
|
|
4
|
+
// composition is S3-owned, while crypto, secrets and I/O are supplied by hosts.
|
|
5
|
+
const { S3_LIMITS } = require('@pulse-compute/wasm-contracts/s3/contracts');
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
const namePattern = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
8
|
+
const bytes = (text) => encoder.encode(text);
|
|
9
|
+
const hex = (value) => Array.from(value, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
10
|
+
function record(value, fields) {
|
|
11
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new TypeError('S3 expects an ordinary record.');
|
|
12
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
13
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
14
|
+
if (!fields.includes(key) || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) throw new TypeError('S3 record contains an unsupported field.');
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function normalizeBinding(value) {
|
|
19
|
+
const input = record(value, ['endpoint', 'bucket', 'region', 'accessKeyIdSecret', 'secretAccessKeySecret', 'sessionTokenSecret', 'maxTextBytes', 'timeoutMs']);
|
|
20
|
+
if (typeof input.endpoint !== 'string' || !/^https:\/\/[a-z0-9.-]+\/?$/.test(input.endpoint)) throw new TypeError('S3 endpoint must be a fixed HTTPS origin.');
|
|
21
|
+
const url = new URL(input.endpoint);
|
|
22
|
+
if (url.hostname.length > 253 || !url.hostname.includes('.') || url.hostname.split('.').some((part) => !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(part))) throw new TypeError('S3 endpoint hostname is invalid.');
|
|
23
|
+
if (typeof input.bucket !== 'string' || !/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(input.bucket) || input.bucket.includes('--')) throw new TypeError('S3 bucket is outside the portable naming subset.');
|
|
24
|
+
if (typeof input.region !== 'string' || !/^[a-z0-9-]{1,64}$/.test(input.region)) throw new TypeError('S3 signing region must be explicit.');
|
|
25
|
+
for (const field of ['accessKeyIdSecret', 'secretAccessKeySecret', 'sessionTokenSecret']) {
|
|
26
|
+
if (field === 'sessionTokenSecret' && input[field] === undefined) continue;
|
|
27
|
+
if (typeof input[field] !== 'string' || !namePattern.test(input[field])) throw new TypeError('S3 credentials must be valid named secret references.');
|
|
28
|
+
}
|
|
29
|
+
const maxTextBytes = input.maxTextBytes === undefined ? S3_LIMITS.textBytes : input.maxTextBytes;
|
|
30
|
+
const timeoutMs = input.timeoutMs === undefined ? 10000 : input.timeoutMs;
|
|
31
|
+
if (!Number.isInteger(maxTextBytes) || maxTextBytes < 1 || maxTextBytes > S3_LIMITS.textBytes || !Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000) throw new TypeError('S3 limits are outside the bounded subset.');
|
|
32
|
+
return Object.freeze({ ...input, endpoint: url.origin, maxTextBytes, timeoutMs });
|
|
33
|
+
}
|
|
34
|
+
function encodeKey(key) {
|
|
35
|
+
if (typeof key !== 'string' || !key.isWellFormed() || !key.length || bytes(key).length > S3_LIMITS.keyBytes
|
|
36
|
+
|| /[\u0000-\u001f\u007f-\u009f]/u.test(key) || key.split('/').some((p) => p === '.' || p === '..')) return null;
|
|
37
|
+
// One-time adaptation of Assets' RFC3986 segment encoder. Deliberately no
|
|
38
|
+
// AssetBucket normalization, path joining, middleware or cache policy.
|
|
39
|
+
return key.split('/').map((part) => encodeURIComponent(part).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)).join('/');
|
|
40
|
+
}
|
|
41
|
+
function credentialsValid(accessId, secret, token) {
|
|
42
|
+
return typeof accessId === 'string' && /^[\x21-\x7e]{1,256}$/.test(accessId)
|
|
43
|
+
&& typeof secret === 'string' && secret.isWellFormed() && bytes(secret).length > 0 && bytes(secret).length <= 4096 && !/[\r\n]/.test(secret)
|
|
44
|
+
&& (token === undefined || typeof token === 'string' && /^[\x21-\x7e]{1,4096}$/.test(token));
|
|
45
|
+
}
|
|
46
|
+
function normalizePutOptions(options = {}) {
|
|
47
|
+
record(options, ['contentType']);
|
|
48
|
+
const contentType = options.contentType === undefined ? 'text/plain; charset=utf-8' : options.contentType;
|
|
49
|
+
if (typeof contentType !== 'string' || !/^[\x20-\x7e]{1,128}$/.test(contentType) || contentType.trim() !== contentType) throw new TypeError('S3 content type is outside the bounded literal subset.');
|
|
50
|
+
return Object.freeze({ contentType });
|
|
51
|
+
}
|
|
52
|
+
async function signRequest(input, crypto) {
|
|
53
|
+
const { binding, method, encodedKey, accessId, secret, token, now } = input;
|
|
54
|
+
const body = method === 'PUT' ? input.body : new Uint8Array();
|
|
55
|
+
const contentType = method === 'PUT' ? normalizePutOptions({ contentType: input.contentType }).contentType : undefined;
|
|
56
|
+
if (!['HEAD', 'GET', 'PUT'].includes(method) || !(body instanceof Uint8Array) || body.length > binding.maxTextBytes || !credentialsValid(accessId, secret, token) || !Number.isFinite(now) || now < 0) throw new TypeError('S3 signing input is invalid.');
|
|
57
|
+
const date = new Date(now).toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
58
|
+
if (!/^\d{8}T\d{6}Z$/.test(date)) throw new TypeError('S3 signing clock is invalid.');
|
|
59
|
+
const digest = hex(await crypto.sha256(body));
|
|
60
|
+
const host = new URL(binding.endpoint).host;
|
|
61
|
+
const headers = { ...(contentType === undefined ? {} : { 'content-type': contentType }), host, 'x-amz-content-sha256': digest, 'x-amz-date': date, ...(token === undefined ? {} : { 'x-amz-security-token': token }) };
|
|
62
|
+
const names = Object.keys(headers).sort();
|
|
63
|
+
const uri = `/${binding.bucket}/${encodedKey}`;
|
|
64
|
+
const canonical = `${method}\n${uri}\n\n${names.map((name) => `${name}:${headers[name].replace(/ +/g, ' ')}\n`).join('')}\n${names.join(';')}\n${digest}`;
|
|
65
|
+
const scope = `${date.slice(0, 8)}/${binding.region}/s3/aws4_request`;
|
|
66
|
+
const toSign = `AWS4-HMAC-SHA256\n${date}\n${scope}\n${hex(await crypto.sha256(bytes(canonical)))}`;
|
|
67
|
+
let key = bytes(`AWS4${secret}`);
|
|
68
|
+
try {
|
|
69
|
+
for (const part of [date.slice(0, 8), binding.region, 's3', 'aws4_request']) {
|
|
70
|
+
const next = await crypto.hmacSha256(key, bytes(part)); key.fill(0); key = next;
|
|
71
|
+
}
|
|
72
|
+
const signature = await crypto.hmacSha256(key, bytes(toSign));
|
|
73
|
+
headers.authorization = `AWS4-HMAC-SHA256 Credential=${accessId}/${scope}, SignedHeaders=${names.join(';')}, Signature=${hex(signature)}`;
|
|
74
|
+
signature.fill(0);
|
|
75
|
+
headers['accept-encoding'] = 'identity';
|
|
76
|
+
return { method, url: binding.endpoint + uri, headers, ...(method === 'PUT' ? { body } : {}) };
|
|
77
|
+
} finally { key.fill(0); }
|
|
78
|
+
}
|
|
79
|
+
function failure(reason, httpStatus) { return Object.freeze({ status: 'failed', reason, ...(httpStatus === undefined ? {} : { httpStatus }) }); }
|
|
80
|
+
function putFailure(reason, dispatched, httpStatus) {
|
|
81
|
+
return Object.freeze({ status: dispatched ? 'unknown' : 'not-stored', reason, ...(httpStatus === undefined ? {} : { httpStatus }) });
|
|
82
|
+
}
|
|
83
|
+
function putStatusResult(status) {
|
|
84
|
+
if (status === 200) return null;
|
|
85
|
+
if (status >= 400 && status <= 499 && status !== 408) return putFailure(status === 401 || status === 403 ? 'not-authorized' : status === 429 ? 'throttled' : 'rejected', false, status);
|
|
86
|
+
return putFailure(status === 408 ? 'timeout' : status >= 500 && status <= 599 ? 'unavailable' : 'protocol', true, status);
|
|
87
|
+
}
|
|
88
|
+
function statusResult(status) {
|
|
89
|
+
if (status === 200) return null;
|
|
90
|
+
if (status === 404) return Object.freeze({ status: 'not-found' });
|
|
91
|
+
return failure(status === 401 || status === 403 ? 'not-authorized' : status === 429 ? 'throttled' : status === 408 ? 'timeout' : status >= 500 && status <= 599 ? 'unavailable' : 'protocol', status);
|
|
92
|
+
}
|
|
93
|
+
function metadataFromHeaders(headers, head) {
|
|
94
|
+
if (!Array.isArray(headers)) return null;
|
|
95
|
+
const selected = new Map(); let total = 0;
|
|
96
|
+
for (const [rawName, value] of headers) {
|
|
97
|
+
total += bytes(rawName).length + bytes(value).length + 4;
|
|
98
|
+
if (total > S3_LIMITS.headerBytes) return null;
|
|
99
|
+
const name = rawName.toLowerCase();
|
|
100
|
+
if (!['content-length', 'content-type', 'etag', 'content-encoding'].includes(name)) continue;
|
|
101
|
+
if (selected.has(name) || !value.isWellFormed() || /[\u0000-\u001f\u007f-\u009f]/u.test(value) || bytes(value).length > S3_LIMITS.metadataBytes) return null;
|
|
102
|
+
selected.set(name, value);
|
|
103
|
+
}
|
|
104
|
+
if (selected.has('content-encoding') && selected.get('content-encoding').toLowerCase() !== 'identity') return null;
|
|
105
|
+
const length = selected.get('content-length');
|
|
106
|
+
if ((head && length === undefined) || (length !== undefined && (!/^(0|[1-9][0-9]*)$/.test(length) || !Number.isSafeInteger(Number(length))))) return null;
|
|
107
|
+
if (selected.has('etag') && !selected.get('etag').length) return null;
|
|
108
|
+
return { ...(length === undefined ? {} : { byteLength: Number(length) }),
|
|
109
|
+
...(selected.has('etag') ? { etag: selected.get('etag') } : {}),
|
|
110
|
+
...(selected.has('content-type') ? { contentType: selected.get('content-type') } : {}) };
|
|
111
|
+
}
|
|
112
|
+
function normalizeResult(operation, result) {
|
|
113
|
+
if (!['head', 'getText', 'putText'].includes(operation)) throw new TypeError('Unknown S3 result operation.');
|
|
114
|
+
const allowed = ['status', 'reason', 'httpStatus', 'byteLength', 'etag', 'contentType', 'text', 'sha256'];
|
|
115
|
+
record(result, allowed);
|
|
116
|
+
if (operation === 'putText') {
|
|
117
|
+
const reasons = result.status === 'not-stored' ? ['invalid-key', 'invalid-text', 'too-large', 'configuration', 'credentials', 'not-authorized', 'rejected', 'throttled', 'transport', 'timeout'] : result.status === 'unknown' ? ['transport', 'timeout', 'unavailable', 'protocol'] : [];
|
|
118
|
+
if (reasons.includes(result.reason) && Object.keys(result).every((key) => ['status', 'reason', 'httpStatus'].includes(key))
|
|
119
|
+
&& (result.httpStatus === undefined || Number.isInteger(result.httpStatus) && result.httpStatus >= 100 && result.httpStatus <= 599)) return Object.freeze({ ...result });
|
|
120
|
+
if (result.status !== 'stored' || !Number.isInteger(result.byteLength) || result.byteLength < 0 || result.byteLength > S3_LIMITS.textBytes || typeof result.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(result.sha256)
|
|
121
|
+
|| Object.keys(result).some((key) => !['status', 'byteLength', 'sha256', 'etag'].includes(key))
|
|
122
|
+
|| result.etag !== undefined && (typeof result.etag !== 'string' || !result.etag.length || !result.etag.isWellFormed() || bytes(result.etag).length > S3_LIMITS.metadataBytes || /[\u0000-\u001f\u007f-\u009f]/u.test(result.etag))) throw new TypeError('PUT returned an invalid bounded result.');
|
|
123
|
+
return Object.freeze({ ...result });
|
|
124
|
+
}
|
|
125
|
+
if (result.status === 'not-found' && Object.keys(result).length === 1) return Object.freeze({ ...result });
|
|
126
|
+
if (result.status === 'failed' && ['invalid-key', 'configuration', 'credentials', 'not-authorized', 'throttled', 'unavailable', 'transport', 'timeout', 'protocol', 'too-large', 'invalid-utf8'].includes(result.reason)
|
|
127
|
+
&& Object.keys(result).every((name) => ['status', 'reason', 'httpStatus'].includes(name))
|
|
128
|
+
&& (result.httpStatus === undefined || Number.isInteger(result.httpStatus) && result.httpStatus >= 100 && result.httpStatus <= 599)) return Object.freeze({ ...result });
|
|
129
|
+
if (result.status !== 'found' || !Number.isSafeInteger(result.byteLength) || result.byteLength < 0 || Object.hasOwn(result, 'reason') || Object.hasOwn(result, 'httpStatus')) throw new TypeError('S3 returned an invalid bounded result.');
|
|
130
|
+
if (operation === 'head' && (Object.hasOwn(result, 'text') || Object.hasOwn(result, 'sha256'))) throw new TypeError('HEAD cannot return object bytes or a digest.');
|
|
131
|
+
if (operation === 'getText' && (typeof result.text !== 'string' || !result.text.isWellFormed() || bytes(result.text).length !== result.byteLength || result.byteLength > S3_LIMITS.textBytes || typeof result.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(result.sha256))) throw new TypeError('GET returned an invalid exact-text result.');
|
|
132
|
+
for (const field of ['etag', 'contentType']) if (result[field] !== undefined && (typeof result[field] !== 'string' || !result[field].isWellFormed() || bytes(result[field]).length > S3_LIMITS.metadataBytes || /[\u0000-\u001f\u007f-\u009f]/u.test(result[field]) || field === 'etag' && !result[field].length)) throw new TypeError('S3 metadata exceeds its result contract.');
|
|
133
|
+
if (bytes(JSON.stringify(result)).length > S3_LIMITS.envelopeBytes) throw new TypeError('S3 result envelope exceeds its bound.');
|
|
134
|
+
return Object.freeze({ ...result });
|
|
135
|
+
}
|
|
136
|
+
module.exports = Object.freeze({ normalizeBinding, namePattern, encodeKey, credentialsValid, normalizePutOptions, signRequest, signRead: signRequest, failure, putFailure, putStatusResult, statusResult, metadataFromHeaders, normalizeResult, hex, bytes });
|