@mpgd/cli 0.39.0 → 0.40.0
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/dist/asset-pack-build.js +4 -1
- package/dist/asset-pack-native-audit.d.ts +5 -0
- package/dist/asset-pack-native-audit.js +137 -0
- package/dist/asset-pack-publish.d.ts +43 -0
- package/dist/asset-pack-publish.js +309 -0
- package/dist/asset-pack-target.d.ts +42 -0
- package/dist/asset-pack-target.js +251 -0
- package/dist/index.js +83 -3
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +3404 -621
- package/package.json +13 -8
package/dist/asset-pack-build.js
CHANGED
|
@@ -107,7 +107,7 @@ function planAssetFiles(asset, rootPath, delivery) {
|
|
|
107
107
|
{ role: 'texture', entryPath: asset.texture },
|
|
108
108
|
{ role: 'atlas', entryPath: asset.atlas },
|
|
109
109
|
]
|
|
110
|
-
: [{ role: 'texture', entryPath: asset.file }];
|
|
110
|
+
: [{ role: asset.kind === 'audio' ? 'audio' : 'texture', entryPath: asset.file }];
|
|
111
111
|
return sources.map(({ role, entryPath }) => {
|
|
112
112
|
const media = phaserPackMediaTypeForPath(entryPath);
|
|
113
113
|
if (media === null) {
|
|
@@ -119,6 +119,9 @@ function planAssetFiles(asset, rootPath, delivery) {
|
|
|
119
119
|
if (role === 'atlas' && media.mediaType !== 'application/json') {
|
|
120
120
|
throw new Error(`Atlas metadata sources must be JSON: ${asset.key}/${entryPath}`);
|
|
121
121
|
}
|
|
122
|
+
if (role === 'audio' && !media.mediaType.startsWith('audio/')) {
|
|
123
|
+
throw new Error(`Audio sources must use an audio format: ${asset.key}/${entryPath}`);
|
|
124
|
+
}
|
|
122
125
|
const data = readSourceFile(rootPath, entryPath);
|
|
123
126
|
let method = asset.compression ?? media.defaultMethod;
|
|
124
127
|
let stored;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type AssetPackTargetReport } from './asset-pack-target.js';
|
|
2
|
+
/** Inspect the actual native release, not only its pre-sync web input.
|
|
3
|
+
* Hash one expanded entry at a time without extracting or executing anything.
|
|
4
|
+
* Bounds: 2 GiB archive, 512 MiB/entry, 4 GiB expanded, 100000 entries. */
|
|
5
|
+
export declare function assertAssetPackNativeArtifact(report: AssetPackTargetReport, artifact: string): Promise<void>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, extname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { openPromise } from 'yauzl';
|
|
5
|
+
import { assertAssetPackTargetArtifact, assetPackTargetManifestName, assetPackTargetNamespace, assetPackTargetPolicyName, } from './asset-pack-target.js';
|
|
6
|
+
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
7
|
+
const manifestSuffix = `/${assetPackTargetNamespace}/${assetPackTargetManifestName}`;
|
|
8
|
+
function expectedNamespace(artifact, namespace) {
|
|
9
|
+
switch (extname(artifact)) {
|
|
10
|
+
case '.apk':
|
|
11
|
+
return namespace === `assets/public/${assetPackTargetNamespace}/`;
|
|
12
|
+
case '.aab':
|
|
13
|
+
return namespace === `base/assets/public/${assetPackTargetNamespace}/`;
|
|
14
|
+
case '.ipa':
|
|
15
|
+
return new RegExp(`^Payload/[^/]+\\.app/public/${assetPackTargetNamespace}/$`).test(namespace);
|
|
16
|
+
case '.app':
|
|
17
|
+
return namespace === `public/${assetPackTargetNamespace}/`;
|
|
18
|
+
case '.xcarchive':
|
|
19
|
+
return new RegExp(`^Products/Applications/[^/]+\\.app/public/${assetPackTargetNamespace}/$`).test(namespace);
|
|
20
|
+
default:
|
|
21
|
+
return basename(artifact) === 'capacitor-sync' && namespace === `App/App/public/${assetPackTargetNamespace}/`;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Inspect the actual native release, not only its pre-sync web input.
|
|
25
|
+
* Hash one expanded entry at a time without extracting or executing anything.
|
|
26
|
+
* Bounds: 2 GiB archive, 512 MiB/entry, 4 GiB expanded, 100000 entries. */
|
|
27
|
+
export async function assertAssetPackNativeArtifact(report, artifact) {
|
|
28
|
+
const path = resolve(artifact);
|
|
29
|
+
const stat = await lstat(path);
|
|
30
|
+
if (stat.isSymbolicLink()) {
|
|
31
|
+
throw new Error('Native asset artifact must not be a symbolic link');
|
|
32
|
+
}
|
|
33
|
+
if (stat.isDirectory()) {
|
|
34
|
+
const candidates = [];
|
|
35
|
+
const pending = [path];
|
|
36
|
+
let entries = 0;
|
|
37
|
+
while (pending.length) {
|
|
38
|
+
const directory = pending.pop();
|
|
39
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
40
|
+
if (++entries > 100_000 || entry.isSymbolicLink()) {
|
|
41
|
+
throw new Error('Native asset artifact inventory is excessive or contains a symbolic link');
|
|
42
|
+
}
|
|
43
|
+
const file = join(directory, entry.name);
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
pending.push(file);
|
|
46
|
+
}
|
|
47
|
+
else if (entry.name === assetPackTargetManifestName && basename(directory) === assetPackTargetNamespace) {
|
|
48
|
+
candidates.push(relative(path, directory));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (candidates.length !== 1) {
|
|
53
|
+
throw new Error('Native asset artifact requires exactly one packaged pack namespace');
|
|
54
|
+
}
|
|
55
|
+
if (!expectedNamespace(path, candidates[0].replaceAll('\\', '/') + '/')) {
|
|
56
|
+
throw new Error('Native asset namespace is outside the platform web bundle');
|
|
57
|
+
}
|
|
58
|
+
await assertAssetPackTargetArtifact(report, path, candidates[0]);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (!stat.isFile() || stat.size > 2 * 1024 ** 3) {
|
|
62
|
+
throw new Error('Native asset archive is not a bounded regular file');
|
|
63
|
+
}
|
|
64
|
+
const localHashes = new Set(report.objects.filter((object) => object.location === 'packaged').map((object) => object.sha256));
|
|
65
|
+
const remoteHashes = new Set(report.objects.filter((object) => object.location === 'remote' && !localHashes.has(object.sha256)).map((object) => object.sha256));
|
|
66
|
+
const files = new Map();
|
|
67
|
+
let expanded = 0;
|
|
68
|
+
let entryCount = 0;
|
|
69
|
+
const archive = await openPromise(path, {
|
|
70
|
+
lazyEntries: true,
|
|
71
|
+
strictFileNames: true,
|
|
72
|
+
validateEntrySizes: true,
|
|
73
|
+
});
|
|
74
|
+
try {
|
|
75
|
+
for await (const entry of archive.eachEntry()) {
|
|
76
|
+
if (++entryCount > 100_000 || entry.uncompressedSize > 512 * 1024 ** 2
|
|
77
|
+
|| expanded + entry.uncompressedSize > 4 * 1024 ** 3) {
|
|
78
|
+
throw new Error('Native asset archive exceeds its inspection bounds');
|
|
79
|
+
}
|
|
80
|
+
if (entry.fileName.endsWith('/')) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (files.has(entry.fileName)) {
|
|
84
|
+
throw new Error('Native asset archive contains duplicate entries');
|
|
85
|
+
}
|
|
86
|
+
const stream = await archive.openReadStreamPromise(entry);
|
|
87
|
+
const hash = createHash('sha256');
|
|
88
|
+
let bytes = 0;
|
|
89
|
+
try {
|
|
90
|
+
for await (const chunk of stream) {
|
|
91
|
+
bytes += chunk.length;
|
|
92
|
+
if (bytes > entry.uncompressedSize) {
|
|
93
|
+
throw new Error('Native asset entry exceeds its declared size');
|
|
94
|
+
}
|
|
95
|
+
hash.update(chunk);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
stream.destroy();
|
|
100
|
+
}
|
|
101
|
+
expanded += bytes;
|
|
102
|
+
if (bytes !== entry.uncompressedSize) {
|
|
103
|
+
throw new Error('Native asset entry has a mismatched expanded size');
|
|
104
|
+
}
|
|
105
|
+
const sha256 = hash.digest('hex');
|
|
106
|
+
if (remoteHashes.has(sha256)) {
|
|
107
|
+
throw new Error(`Remote-only asset object is present in the native package: ${entry.fileName}`);
|
|
108
|
+
}
|
|
109
|
+
files.set(entry.fileName, { bytes, sha256 });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
archive.close();
|
|
114
|
+
}
|
|
115
|
+
const namespaces = [...files.keys()].filter((name) => name.endsWith(manifestSuffix)).map((name) => name.slice(0, -assetPackTargetManifestName.length));
|
|
116
|
+
if (namespaces.length !== 1) {
|
|
117
|
+
throw new Error('Native asset package requires exactly one packaged pack namespace');
|
|
118
|
+
}
|
|
119
|
+
const namespace = namespaces[0];
|
|
120
|
+
if (!expectedNamespace(path, namespace)) {
|
|
121
|
+
throw new Error('Native asset namespace is outside the platform web bundle');
|
|
122
|
+
}
|
|
123
|
+
const namespaceBytes = [...files].filter(([name]) => name.startsWith(namespace)).reduce((sum, [, file]) => sum + file.bytes, 0);
|
|
124
|
+
if (namespaceBytes !== report.packagedBytes) {
|
|
125
|
+
throw new Error('Native packaged asset namespace has extra, missing or modified bytes');
|
|
126
|
+
}
|
|
127
|
+
for (const name of [assetPackTargetManifestName, assetPackTargetPolicyName]) {
|
|
128
|
+
if (files.get(namespace + name)?.sha256 !== digest(await readFile(join(report.packagedDir, name)))) {
|
|
129
|
+
throw new Error('Native packaged asset metadata changed');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const object of report.objects.filter((object) => object.location === 'packaged')) {
|
|
133
|
+
if (files.get(namespace + object.path)?.sha256 !== object.sha256) {
|
|
134
|
+
throw new Error('Native packaged asset object changed');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export interface AssetPackPublicationOptions {
|
|
2
|
+
/** Fresh stage-target output with packaged/ and remote/ children. */
|
|
3
|
+
readonly deployment: string;
|
|
4
|
+
readonly endpoint: string;
|
|
5
|
+
readonly bucket: string;
|
|
6
|
+
readonly prefix: string;
|
|
7
|
+
readonly region?: string;
|
|
8
|
+
readonly dryRun?: boolean;
|
|
9
|
+
/** Explicit local protocol fixture opt-in; HTTPS remains the production default. */
|
|
10
|
+
readonly allowHttpLoopback?: boolean;
|
|
11
|
+
readonly accessKeyEnv?: string;
|
|
12
|
+
readonly secretKeyEnv?: string;
|
|
13
|
+
readonly sessionTokenEnv?: string;
|
|
14
|
+
readonly maxObjectBytes?: number;
|
|
15
|
+
readonly maxTotalBytes?: number;
|
|
16
|
+
readonly maxObjects?: number;
|
|
17
|
+
readonly requestTimeoutMs?: number;
|
|
18
|
+
readonly timeoutMs?: number;
|
|
19
|
+
readonly signal?: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
export interface AssetPackPublicationReport {
|
|
22
|
+
readonly dryRun: boolean;
|
|
23
|
+
readonly endpoint: string;
|
|
24
|
+
readonly bucket: string;
|
|
25
|
+
readonly prefix: string;
|
|
26
|
+
readonly manifestSha256: string;
|
|
27
|
+
readonly plannedBytes: number;
|
|
28
|
+
/** Object body bytes only, not HTTP headers, retries or physical wire bytes. */
|
|
29
|
+
readonly uploadedBytes: number;
|
|
30
|
+
readonly objects: readonly {
|
|
31
|
+
readonly key: string;
|
|
32
|
+
readonly bytes: number;
|
|
33
|
+
readonly sha256: string;
|
|
34
|
+
readonly action: 'planned' | 'uploaded' | 'reused';
|
|
35
|
+
}[];
|
|
36
|
+
}
|
|
37
|
+
export declare class AssetPackPublicationError extends Error {
|
|
38
|
+
readonly code: 'config' | 'source' | 'integrity' | 'limit' | 'collision' | 's3' | 'timeout' | 'cancelled';
|
|
39
|
+
constructor(code: 'config' | 'source' | 'integrity' | 'limit' | 'collision' | 's3' | 'timeout' | 'cancelled', message: string);
|
|
40
|
+
}
|
|
41
|
+
/** The deployment tool owns all storage credentials; no ambient AWS profile or
|
|
42
|
+
* credentials provider chain is consulted, and SDK errors never reach logs. */
|
|
43
|
+
export declare function publishAssetPacks(options: AssetPackPublicationOptions): Promise<AssetPackPublicationReport>;
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, open, realpath } from 'node:fs/promises';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import { HeadObjectCommand, PutObjectCommand, S3Client, } from '@aws-sdk/client-s3';
|
|
7
|
+
import { NodeHttpHandler } from '@smithy/node-http-handler';
|
|
8
|
+
import { parsePhaserPackEntryPath, validatePhaserPackDeliveryManifest, } from '@mpgd/phaser-assets/pack-format';
|
|
9
|
+
import { assertAssetPackDeliveryPolicy, resolveAssetPackLocations, } from '@mpgd/target-config/asset-packs';
|
|
10
|
+
import { assetPackTargetManifestName, assetPackTargetPolicyName } from './asset-pack-target.js';
|
|
11
|
+
export class AssetPackPublicationError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
constructor(code, message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.name = 'AssetPackPublicationError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
20
|
+
const immutableCacheControl = 'public, max-age=31536000, immutable';
|
|
21
|
+
const fail = (code, message) => {
|
|
22
|
+
throw new AssetPackPublicationError(code, message);
|
|
23
|
+
};
|
|
24
|
+
const positive = (value, maximum, field) => {
|
|
25
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) {
|
|
26
|
+
fail('config', `Invalid bounded publication option: ${field}`);
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
};
|
|
30
|
+
async function readBounded(path, maximum, signal) {
|
|
31
|
+
const stat = await lstat(path);
|
|
32
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
33
|
+
fail('source', 'Publication source must be a regular non-symlink file');
|
|
34
|
+
}
|
|
35
|
+
if (stat.size > maximum) {
|
|
36
|
+
fail('limit', 'Publication source exceeds its byte limit');
|
|
37
|
+
}
|
|
38
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
39
|
+
const stream = handle.createReadStream({
|
|
40
|
+
signal,
|
|
41
|
+
highWaterMark: 64 * 1024,
|
|
42
|
+
});
|
|
43
|
+
const chunks = [];
|
|
44
|
+
let size = 0;
|
|
45
|
+
try {
|
|
46
|
+
for await (const chunk of stream) {
|
|
47
|
+
const bytes = chunk;
|
|
48
|
+
size += bytes.length;
|
|
49
|
+
if (size > maximum) {
|
|
50
|
+
fail('limit', 'Publication source grew beyond its byte limit');
|
|
51
|
+
}
|
|
52
|
+
chunks.push(bytes);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
stream.destroy();
|
|
57
|
+
await handle.close();
|
|
58
|
+
}
|
|
59
|
+
return Buffer.concat(chunks, size);
|
|
60
|
+
}
|
|
61
|
+
async function safeObjectPath(root, path) {
|
|
62
|
+
parsePhaserPackEntryPath(path);
|
|
63
|
+
let current = root;
|
|
64
|
+
for (const component of path.split('/')) {
|
|
65
|
+
current = join(current, component);
|
|
66
|
+
if ((await lstat(current)).isSymbolicLink()) {
|
|
67
|
+
fail('source', 'Publication object path traverses a symbolic link');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return current;
|
|
71
|
+
}
|
|
72
|
+
function httpStatus(error) {
|
|
73
|
+
if (typeof error !== 'object' || error === null || !('$metadata' in error)) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const status = error.$metadata?.httpStatusCode;
|
|
77
|
+
return typeof status === 'number' && Number.isInteger(status) && status >= 100 && status <= 599
|
|
78
|
+
? status
|
|
79
|
+
: undefined;
|
|
80
|
+
}
|
|
81
|
+
/** The deployment tool owns all storage credentials; no ambient AWS profile or
|
|
82
|
+
* credentials provider chain is consulted, and SDK errors never reach logs. */
|
|
83
|
+
export async function publishAssetPacks(options) {
|
|
84
|
+
let endpoint;
|
|
85
|
+
try {
|
|
86
|
+
endpoint = new URL(options.endpoint);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return fail('config', 'Publication endpoint must be an HTTPS root URL');
|
|
90
|
+
}
|
|
91
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(endpoint.hostname);
|
|
92
|
+
if ((endpoint.protocol !== 'https:' && !(options.allowHttpLoopback === true && endpoint.protocol === 'http:' && loopback))
|
|
93
|
+
|| endpoint.pathname !== '/' || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
|
|
94
|
+
fail('config', 'Publication endpoint requires HTTPS without credentials, path, query or fragment (explicit HTTP loopback opt-in available)');
|
|
95
|
+
}
|
|
96
|
+
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(options.bucket) || options.bucket.includes('..')
|
|
97
|
+
|| /^\d+\.\d+\.\d+\.\d+$/.test(options.bucket)) {
|
|
98
|
+
fail('config', 'Publication requires a regular S3 bucket name, not an ARN');
|
|
99
|
+
}
|
|
100
|
+
let prefix;
|
|
101
|
+
try {
|
|
102
|
+
prefix = parsePhaserPackEntryPath(options.prefix.replace(/\/$/, ''));
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return fail('config', 'Publication prefix must be a non-empty normalized relative object path');
|
|
106
|
+
}
|
|
107
|
+
const region = options.region ?? 'us-east-1';
|
|
108
|
+
if (!/^[a-z0-9-]{1,64}$/.test(region)) {
|
|
109
|
+
fail('config', 'Invalid publication region');
|
|
110
|
+
}
|
|
111
|
+
const maxObjectBytes = positive(options.maxObjectBytes ?? 64 * 1024 ** 2, 512 * 1024 ** 2, 'maxObjectBytes');
|
|
112
|
+
const maxTotalBytes = positive(options.maxTotalBytes ?? 1024 ** 3, Number.MAX_SAFE_INTEGER, 'maxTotalBytes');
|
|
113
|
+
const maxObjects = positive(options.maxObjects ?? 10_000, 100_000, 'maxObjects');
|
|
114
|
+
const requestTimeoutMs = positive(options.requestTimeoutMs ?? 10_000, 2 ** 31 - 2, 'requestTimeoutMs');
|
|
115
|
+
const timeoutMs = positive(options.timeoutMs ?? 120_000, 2 ** 31 - 2, 'timeoutMs');
|
|
116
|
+
const signal = AbortSignal.any([
|
|
117
|
+
AbortSignal.timeout(timeoutMs),
|
|
118
|
+
...(options.signal === undefined ? [] : [options.signal]),
|
|
119
|
+
]);
|
|
120
|
+
let client;
|
|
121
|
+
try {
|
|
122
|
+
const deployment = await realpath(resolve(options.deployment));
|
|
123
|
+
const metadataRoot = await safeObjectPath(deployment, 'packaged');
|
|
124
|
+
const remoteRoot = await safeObjectPath(deployment, 'remote');
|
|
125
|
+
const manifestBytes = await readBounded(join(metadataRoot, assetPackTargetManifestName), 32 * 1024 ** 2, signal);
|
|
126
|
+
const manifest = validatePhaserPackDeliveryManifest(JSON.parse(manifestBytes.toString('utf8')));
|
|
127
|
+
const policy = JSON.parse((await readBounded(join(metadataRoot, assetPackTargetPolicyName), 1024 ** 2, signal)).toString('utf8'));
|
|
128
|
+
assertAssetPackDeliveryPolicy(policy);
|
|
129
|
+
const locations = resolveAssetPackLocations(policy, manifest.packs);
|
|
130
|
+
const planned = new Map();
|
|
131
|
+
for (const pack of manifest.packs) {
|
|
132
|
+
if (locations[pack.packId] !== 'remote') {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const files = pack.delivery === 'zip'
|
|
136
|
+
? [{ ...pack.archive, mediaType: 'application/zip' }]
|
|
137
|
+
: pack.assets.flatMap((asset) => asset.files);
|
|
138
|
+
for (const file of files) {
|
|
139
|
+
const key = `${prefix}/${file.path}`;
|
|
140
|
+
planned.set(key, {
|
|
141
|
+
key,
|
|
142
|
+
path: file.path,
|
|
143
|
+
bytes: file.bytes,
|
|
144
|
+
sha256: file.sha256,
|
|
145
|
+
mediaType: file.mediaType,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const manifestSha256 = sha(manifestBytes);
|
|
150
|
+
const snapshotKey = `${prefix}/manifests/${manifestSha256}.json`;
|
|
151
|
+
const objects = [...planned.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
152
|
+
// The immutable snapshot is the final publication marker. There is no
|
|
153
|
+
// mutable latest pointer and no claim of a cross-object transaction.
|
|
154
|
+
objects.push({
|
|
155
|
+
key: snapshotKey,
|
|
156
|
+
bytes: manifestBytes.length,
|
|
157
|
+
sha256: manifestSha256,
|
|
158
|
+
mediaType: 'application/json',
|
|
159
|
+
});
|
|
160
|
+
const plannedBytes = objects.reduce((sum, object) => sum + object.bytes, 0);
|
|
161
|
+
if (objects.length > maxObjects || !Number.isSafeInteger(plannedBytes) || plannedBytes > maxTotalBytes
|
|
162
|
+
|| objects.some((object) => object.bytes > maxObjectBytes)) {
|
|
163
|
+
fail('limit', 'Publication plan exceeds its object or byte limits');
|
|
164
|
+
}
|
|
165
|
+
const bytesFor = async (object) => {
|
|
166
|
+
signal.throwIfAborted();
|
|
167
|
+
const bytes = object.path === undefined
|
|
168
|
+
? manifestBytes
|
|
169
|
+
: await readBounded(await safeObjectPath(remoteRoot, object.path), object.bytes, signal);
|
|
170
|
+
if (bytes.length !== object.bytes || sha(bytes) !== object.sha256) {
|
|
171
|
+
fail('integrity', 'Publication object does not match its pinned manifest bytes and digest');
|
|
172
|
+
}
|
|
173
|
+
return bytes;
|
|
174
|
+
};
|
|
175
|
+
// Validate every source before the first external write, then revalidate
|
|
176
|
+
// each object immediately before sending it. Keep only one body resident.
|
|
177
|
+
for (const object of objects) {
|
|
178
|
+
await bytesFor(object);
|
|
179
|
+
}
|
|
180
|
+
const results = [];
|
|
181
|
+
if (options.dryRun === true) {
|
|
182
|
+
for (const object of objects) {
|
|
183
|
+
results.push({
|
|
184
|
+
key: object.key,
|
|
185
|
+
bytes: object.bytes,
|
|
186
|
+
sha256: object.sha256,
|
|
187
|
+
action: 'planned',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
const value = (name, required) => {
|
|
193
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(name)) {
|
|
194
|
+
fail('config', 'Credential options must name environment variables');
|
|
195
|
+
}
|
|
196
|
+
const result = process.env[name];
|
|
197
|
+
if ((required && !result) || (result !== undefined && (result.length > 16_384 || /[\s\x00-\x1f\x7f]/.test(result)))) {
|
|
198
|
+
fail('config', 'Publication credential environment is missing or invalid');
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
};
|
|
202
|
+
const accessKeyId = value(options.accessKeyEnv ?? 'MPGD_ASSETS_S3_ACCESS_KEY_ID', true);
|
|
203
|
+
const secretAccessKey = value(options.secretKeyEnv ?? 'MPGD_ASSETS_S3_SECRET_ACCESS_KEY', true);
|
|
204
|
+
const sessionToken = value(options.sessionTokenEnv ?? 'MPGD_ASSETS_S3_SESSION_TOKEN', false);
|
|
205
|
+
client = new S3Client({
|
|
206
|
+
endpoint: endpoint.href, region, forcePathStyle: true, followRegionRedirects: false, maxAttempts: 1,
|
|
207
|
+
requestChecksumCalculation: 'WHEN_REQUIRED', responseChecksumValidation: 'WHEN_REQUIRED',
|
|
208
|
+
credentials: { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) },
|
|
209
|
+
requestHandler: new NodeHttpHandler({ connectionTimeout: requestTimeoutMs, socketTimeout: requestTimeoutMs, requestTimeout: requestTimeoutMs }),
|
|
210
|
+
});
|
|
211
|
+
const request = async (action) => {
|
|
212
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
213
|
+
const attemptSignal = AbortSignal.any([signal, AbortSignal.timeout(requestTimeoutMs)]);
|
|
214
|
+
try {
|
|
215
|
+
return await action(attemptSignal);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (attemptSignal.aborted) {
|
|
219
|
+
fail(options.signal?.aborted ? 'cancelled' : 'timeout', 'Asset publication request exceeded its deadline or was cancelled');
|
|
220
|
+
}
|
|
221
|
+
if (attempt === 0 && [409, 429, 500, 502, 503, 504].includes(httpStatus(error) ?? 0)) {
|
|
222
|
+
await delay(100, undefined, { signal });
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return fail('s3', 'Asset publication request exhausted its attempt limit');
|
|
229
|
+
};
|
|
230
|
+
const head = async (key) => {
|
|
231
|
+
try {
|
|
232
|
+
return await request((attemptSignal) => client.send(new HeadObjectCommand({ Bucket: options.bucket, Key: key, ChecksumMode: 'ENABLED' }), { abortSignal: attemptSignal }));
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
if (httpStatus(error) === 404) {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
const verifyHead = (object, result) => {
|
|
242
|
+
if (!result || result.ContentLength !== object.bytes || result.Metadata?.['mpgd-sha256'] !== object.sha256
|
|
243
|
+
|| result.ChecksumSHA256 !== Buffer.from(object.sha256, 'hex').toString('base64')
|
|
244
|
+
|| result.ContentType !== object.mediaType || result.CacheControl !== immutableCacheControl) {
|
|
245
|
+
fail('collision', 'Stored object does not match immutable bytes, checksum or delivery metadata; use a new revision/prefix');
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
for (const object of objects) {
|
|
249
|
+
const bytes = await bytesFor(object);
|
|
250
|
+
const existing = await head(object.key);
|
|
251
|
+
let action;
|
|
252
|
+
if (existing) {
|
|
253
|
+
verifyHead(object, existing);
|
|
254
|
+
action = 'reused';
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
action = 'uploaded';
|
|
258
|
+
try {
|
|
259
|
+
await request((attemptSignal) => client.send(new PutObjectCommand({
|
|
260
|
+
Bucket: options.bucket,
|
|
261
|
+
Key: object.key,
|
|
262
|
+
Body: bytes,
|
|
263
|
+
ContentLength: object.bytes,
|
|
264
|
+
ContentType: object.mediaType,
|
|
265
|
+
CacheControl: immutableCacheControl,
|
|
266
|
+
IfNoneMatch: '*',
|
|
267
|
+
ChecksumSHA256: Buffer.from(object.sha256, 'hex').toString('base64'),
|
|
268
|
+
Metadata: { 'mpgd-sha256': object.sha256 },
|
|
269
|
+
}), { abortSignal: attemptSignal }));
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
if (httpStatus(error) !== 412) {
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
action = 'reused';
|
|
276
|
+
}
|
|
277
|
+
verifyHead(object, await head(object.key));
|
|
278
|
+
}
|
|
279
|
+
results.push({ key: object.key, bytes: object.bytes, sha256: object.sha256, action });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
dryRun: options.dryRun === true,
|
|
284
|
+
endpoint: endpoint.href,
|
|
285
|
+
bucket: options.bucket,
|
|
286
|
+
prefix,
|
|
287
|
+
manifestSha256,
|
|
288
|
+
plannedBytes,
|
|
289
|
+
uploadedBytes: results.filter((object) => object.action === 'uploaded').reduce((sum, object) => sum + object.bytes, 0),
|
|
290
|
+
objects: results,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
if (signal.aborted) {
|
|
295
|
+
return fail(options.signal?.aborted ? 'cancelled' : 'timeout', 'Asset publication was cancelled or exceeded its deadline');
|
|
296
|
+
}
|
|
297
|
+
if (error instanceof AssetPackPublicationError) {
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
const status = httpStatus(error);
|
|
301
|
+
// Do not surface SDK messages, XML responses, signed URLs, headers or causes.
|
|
302
|
+
return fail(client === undefined ? 'source' : 's3', status === undefined
|
|
303
|
+
? 'Asset publication failed'
|
|
304
|
+
: `Asset publication failed with HTTP ${status}`);
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
client?.destroy();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type AssetPackDeliveryPolicy, type AssetPackLocation, type AssetPackTargetPolicy } from '@mpgd/target-config/asset-packs';
|
|
2
|
+
export declare const assetPackTargetNamespace = "mpgd-asset-packs";
|
|
3
|
+
export declare const assetPackTargetManifestName = "asset-pack-delivery.json";
|
|
4
|
+
export declare const assetPackTargetPolicyName = "target-policy.json";
|
|
5
|
+
export interface AssetPackTargetReport {
|
|
6
|
+
readonly outDir: string;
|
|
7
|
+
readonly packagedDir: string;
|
|
8
|
+
readonly remoteDir: string;
|
|
9
|
+
readonly manifestSha256: string;
|
|
10
|
+
readonly locations: Readonly<Record<string, AssetPackLocation>>;
|
|
11
|
+
readonly packagedAssetBytes: number;
|
|
12
|
+
readonly packagedBytes: number;
|
|
13
|
+
readonly remoteBytes: number;
|
|
14
|
+
readonly objects: readonly {
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly bytes: number;
|
|
17
|
+
readonly sha256: string;
|
|
18
|
+
readonly location: AssetPackLocation;
|
|
19
|
+
}[];
|
|
20
|
+
}
|
|
21
|
+
/** Stage verified immutable objects into disjoint packaged/remote roots.
|
|
22
|
+
* The output must not exist: a failed attempt never replaces a deployment. */
|
|
23
|
+
export declare function stageAssetPackTarget(options: {
|
|
24
|
+
readonly manifestPath: string;
|
|
25
|
+
readonly root: string;
|
|
26
|
+
readonly policy: AssetPackDeliveryPolicy;
|
|
27
|
+
readonly outDir: string;
|
|
28
|
+
}): Promise<AssetPackTargetReport>;
|
|
29
|
+
/** Build from one target's policy without requiring a kit checkout. */
|
|
30
|
+
export declare function assertAssetPackTargetOutput(options: {
|
|
31
|
+
readonly policy: AssetPackTargetPolicy;
|
|
32
|
+
readonly baseDir: string;
|
|
33
|
+
readonly outDir: string;
|
|
34
|
+
}): Promise<void>;
|
|
35
|
+
export declare function buildAssetPackTarget(options: {
|
|
36
|
+
readonly policy: AssetPackTargetPolicy;
|
|
37
|
+
readonly baseDir: string;
|
|
38
|
+
readonly outDir: string;
|
|
39
|
+
}): Promise<AssetPackTargetReport>;
|
|
40
|
+
/** Verify the real bundle's asset namespace and reject byte-identical remote
|
|
41
|
+
* objects copied elsewhere. This does not inspect transformed/embedded data. */
|
|
42
|
+
export declare function assertAssetPackTargetArtifact(report: AssetPackTargetReport, artifactRoot: string, namespacePath?: string): Promise<void>;
|