@git.zone/tspack 1.1.0 → 1.3.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_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/archive.d.ts +4 -1
- package/dist_ts/archive.js +85 -16
- package/dist_ts/classes.tspack.d.ts +11 -1
- package/dist_ts/classes.tspack.js +144 -44
- package/dist_ts/interfaces.d.ts +24 -1
- package/dist_ts/tspack.cli.js +4 -2
- package/dist_ts/validation.d.ts +2 -1
- package/dist_ts/validation.js +45 -11
- package/package.json +2 -2
- package/readme.md +91 -6
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/archive.ts +80 -16
- package/ts/classes.tspack.ts +155 -40
- package/ts/interfaces.ts +28 -1
- package/ts/tspack.cli.ts +3 -1
- package/ts/validation.ts +48 -11
- package/readme.plan.md +0 -26
package/ts/classes.tspack.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
-
import type { ITsPackConfig, ITsPackOptions, ITsPackResult, ITsPackVerifyOptions, ITsPackFileRecord, ITsPackManifest
|
|
2
|
+
import type { ITsPackConfig, ITsPackOptions, ITsPackResult, ITsPackVerifyOptions, ITsPackFileRecord, ITsPackManifest, ITsPackArtifact,
|
|
3
|
+
ITsPackBundleOptions, ITsPackBundleResult, ITsPackExtractResult } from './interfaces.js';
|
|
3
4
|
import { packArchive, verifyArchive } from './archive.js';
|
|
4
|
-
import { digestFile, isCommit, isDigest, isPath, isVersion, jsonBytes, limits, noSymlinks, normalizeConfig, object,
|
|
5
|
+
import { digestFile, isCommit, isDigest, isName, isPath, isVersion, jsonBytes, limits, noSymlinks, normalizeConfig, object,
|
|
5
6
|
readJson, requireValue, sha256, validateManifest } from './validation.js';
|
|
6
7
|
export { TsPackError } from './validation.js';
|
|
7
8
|
|
|
@@ -16,6 +17,36 @@ const bundleMetadata = (manifest: Omit<ITsPackManifest, 'artifacts'>, id: string
|
|
|
16
17
|
export class TsPack {
|
|
17
18
|
constructor(private readonly cwd = process.cwd()) {}
|
|
18
19
|
|
|
20
|
+
private async copyInput(root: string, source: string, destination: string, remaining: number,
|
|
21
|
+
options: { executable?: boolean; sha256?: string }): Promise<ITsPackFileRecord> {
|
|
22
|
+
const input = await noSymlinks(root, source);
|
|
23
|
+
const handle = await plugins.fs.open(input, plugins.nodeFs.constants.O_RDONLY | plugins.nodeFs.constants.O_NOFOLLOW);
|
|
24
|
+
try {
|
|
25
|
+
const before = await handle.stat({ bigint: true });
|
|
26
|
+
requireValue(before.isFile() && before.size <= BigInt(limits.file), 'unsafe_input', 'Expected a bounded regular input file.');
|
|
27
|
+
requireValue(before.size <= BigInt(remaining), 'unsafe_input', 'Package inputs exceed the total byte limit.');
|
|
28
|
+
await plugins.fs.mkdir(plugins.path.dirname(destination), { recursive: true });
|
|
29
|
+
const hash = plugins.crypto.createHash('sha256');
|
|
30
|
+
let size = 0;
|
|
31
|
+
const meter = new plugins.stream.Transform({ transform(chunk: Buffer, _encoding, callback) {
|
|
32
|
+
size += chunk.length;
|
|
33
|
+
if (size > Number(before.size)) { callback(new Error('Input changed while copying.')); return; }
|
|
34
|
+
hash.update(chunk); callback(null, chunk);
|
|
35
|
+
} });
|
|
36
|
+
await plugins.streamPromises.pipeline(handle.createReadStream({ autoClose: false }), meter,
|
|
37
|
+
plugins.nodeFs.createWriteStream(destination, { flags: 'wx', mode: 0o600 }));
|
|
38
|
+
const after = await handle.stat({ bigint: true });
|
|
39
|
+
const current = await plugins.fs.lstat(input, { bigint: true });
|
|
40
|
+
requireValue(size === Number(before.size) && ['dev', 'ino', 'size', 'mtimeNs', 'ctimeNs'].every((key) =>
|
|
41
|
+
before[key as keyof typeof before] === after[key as keyof typeof after] &&
|
|
42
|
+
before[key as keyof typeof before] === current[key as keyof typeof current]),
|
|
43
|
+
'integrity', 'Input changed while copying.');
|
|
44
|
+
const record: ITsPackFileRecord = { sha256: hash.digest('hex'), size, mode: options.executable ? 0o755 : 0o644 };
|
|
45
|
+
requireValue(!options.sha256 || options.sha256 === record.sha256, 'integrity', 'Input does not match its pinned digest.');
|
|
46
|
+
return record;
|
|
47
|
+
} finally { await handle.close(); }
|
|
48
|
+
}
|
|
49
|
+
|
|
19
50
|
private async source(root: string, version: string, release: boolean) {
|
|
20
51
|
const run = async (...args: string[]) => (await exec('git', args, { cwd: root, maxBuffer: 4 * 1024 ** 2 })).stdout.trim();
|
|
21
52
|
const commit = await run('rev-parse', 'HEAD');
|
|
@@ -41,6 +72,9 @@ export class TsPack {
|
|
|
41
72
|
const config = normalizeConfig(configArg ?? new plugins.smartconfig.Smartconfig(root).dataFor('@git.zone/tspack', {}), packageJson.name);
|
|
42
73
|
requireValue(config.bundles.every((bundle) => isPath(`${config.name}-${packageJson.version}-${bundle.id}.tar.gz`)),
|
|
43
74
|
'invalid_config', 'Archive names exceed the portable path limit.');
|
|
75
|
+
const names = [...config.bundles.map((bundle) => `${config.name}-${packageJson.version}-${bundle.id}.tar.gz`),
|
|
76
|
+
...(config.assets ?? []).map((asset) => asset.name)].map((name) => name.toLowerCase());
|
|
77
|
+
requireValue(new Set(names).size === names.length, 'invalid_config', 'Archive and standalone asset names collide.');
|
|
44
78
|
const configurationSha256 = sha256(jsonBytes(config));
|
|
45
79
|
const source = await this.source(root, packageJson.version, optionsArg.release === true);
|
|
46
80
|
const outputRoot = await noSymlinks(root, config.outputDirectory, true);
|
|
@@ -64,7 +98,7 @@ export class TsPack {
|
|
|
64
98
|
await plugins.fs.mkdir(plugins.path.join(stage, 'bundles'));
|
|
65
99
|
try {
|
|
66
100
|
const manifest: ITsPackManifest = {
|
|
67
|
-
format: 'tspack.release.v1', packageName: packageJson.name, name: config.name, version: packageJson.version,
|
|
101
|
+
format: config.assets?.length ? 'tspack.release.v2' : 'tspack.release.v1', packageName: packageJson.name, name: config.name, version: packageJson.version,
|
|
68
102
|
sourceCommit: source.commit, sourceDirty: source.dirty, publishable: optionsArg.release === true,
|
|
69
103
|
configurationSha256, artifacts: [],
|
|
70
104
|
};
|
|
@@ -74,34 +108,9 @@ export class TsPack {
|
|
|
74
108
|
await plugins.fs.mkdir(directory);
|
|
75
109
|
const files: Record<string, ITsPackFileRecord> = Object.create(null);
|
|
76
110
|
for (const file of bundle.files) {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const before = await handle.stat({ bigint: true });
|
|
81
|
-
requireValue(before.isFile() && before.size <= BigInt(limits.file), 'unsafe_input', 'Expected a bounded regular input file.');
|
|
82
|
-
total += Number(before.size);
|
|
83
|
-
requireValue(total <= limits.total, 'unsafe_input', 'Package inputs exceed the total byte limit.');
|
|
84
|
-
const destination = plugins.path.join(directory, file.path);
|
|
85
|
-
await plugins.fs.mkdir(plugins.path.dirname(destination), { recursive: true });
|
|
86
|
-
const hash = plugins.crypto.createHash('sha256');
|
|
87
|
-
let size = 0;
|
|
88
|
-
const meter = new plugins.stream.Transform({ transform(chunk: Buffer, _encoding, callback) {
|
|
89
|
-
size += chunk.length;
|
|
90
|
-
if (size > Number(before.size)) { callback(new Error('Input changed while copying.')); return; }
|
|
91
|
-
hash.update(chunk); callback(null, chunk);
|
|
92
|
-
} });
|
|
93
|
-
await plugins.streamPromises.pipeline(handle.createReadStream({ autoClose: false }), meter,
|
|
94
|
-
plugins.nodeFs.createWriteStream(destination, { flags: 'wx', mode: 0o600 }));
|
|
95
|
-
const after = await handle.stat({ bigint: true });
|
|
96
|
-
const current = await plugins.fs.lstat(input, { bigint: true });
|
|
97
|
-
requireValue(size === Number(before.size) && ['dev', 'ino', 'size', 'mtimeNs', 'ctimeNs'].every((key) =>
|
|
98
|
-
before[key as keyof typeof before] === after[key as keyof typeof after] &&
|
|
99
|
-
before[key as keyof typeof before] === current[key as keyof typeof current]),
|
|
100
|
-
'integrity', 'Input changed while copying.');
|
|
101
|
-
const record = { sha256: hash.digest('hex'), size, mode: (file.executable ? 0o755 : 0o644) as 493 | 420 };
|
|
102
|
-
requireValue(!file.sha256 || file.sha256 === record.sha256, 'integrity', 'Input does not match its pinned digest.');
|
|
103
|
-
files[file.path] = record;
|
|
104
|
-
} finally { await handle.close(); }
|
|
111
|
+
const record = await this.copyInput(root, file.source, plugins.path.join(directory, file.path), limits.total - total, file);
|
|
112
|
+
total += record.size;
|
|
113
|
+
files[file.path] = record;
|
|
105
114
|
}
|
|
106
115
|
const metadata = jsonBytes(bundleMetadata(manifest, bundle.id, files));
|
|
107
116
|
requireValue(metadata.length <= limits.json, 'invalid_config', 'Bundle metadata exceeds its limit.');
|
|
@@ -111,6 +120,15 @@ export class TsPack {
|
|
|
111
120
|
await packArchive(directory, plugins.path.join(assets, name), files);
|
|
112
121
|
manifest.artifacts.push({ id: bundle.id, name, ...await digestFile(plugins.path.join(assets, name)), files });
|
|
113
122
|
}
|
|
123
|
+
for (const asset of config.assets ?? []) {
|
|
124
|
+
const file = plugins.path.join(assets, asset.name);
|
|
125
|
+
const record = await this.copyInput(root, asset.source, file, limits.total - total, asset);
|
|
126
|
+
total += record.size;
|
|
127
|
+
await plugins.fs.chmod(file, record.mode);
|
|
128
|
+
manifest.artifacts.push({ kind: 'file', id: asset.id, name: asset.name, sha256: record.sha256,
|
|
129
|
+
size: record.size, files: { [asset.name]: record } });
|
|
130
|
+
}
|
|
131
|
+
manifest.artifacts.sort((a, b) => a.id.localeCompare(b.id, 'en'));
|
|
114
132
|
const finalSource = await this.source(root, packageJson.version, optionsArg.release === true);
|
|
115
133
|
requireValue(finalSource.commit === source.commit && finalSource.dirty === source.dirty, 'source_state', 'Source identity changed while packaging.');
|
|
116
134
|
validateManifest(manifest);
|
|
@@ -135,7 +153,8 @@ export class TsPack {
|
|
|
135
153
|
.sort(([a], [b]) => a!.localeCompare(b!, 'en')).map(([name, hash]) => `${hash} ${name}`).join('\n') + '\n';
|
|
136
154
|
}
|
|
137
155
|
|
|
138
|
-
|
|
156
|
+
private async readArtifactSet(directoryArg: string, optionsArg: ITsPackVerifyOptions,
|
|
157
|
+
bundleId?: string): Promise<ITsPackResult> {
|
|
139
158
|
object(optionsArg, ['expectedManifestSha256', 'expectedSourceCommit', 'requireRelease']);
|
|
140
159
|
requireValue((optionsArg.expectedManifestSha256 === undefined || isDigest(optionsArg.expectedManifestSha256)) &&
|
|
141
160
|
(optionsArg.expectedSourceCommit === undefined || isCommit(optionsArg.expectedSourceCommit)) &&
|
|
@@ -151,20 +170,116 @@ export class TsPack {
|
|
|
151
170
|
requireValue((!optionsArg.expectedManifestSha256 || optionsArg.expectedManifestSha256 === manifestSha256) &&
|
|
152
171
|
(!optionsArg.expectedSourceCommit || optionsArg.expectedSourceCommit === manifest.sourceCommit) &&
|
|
153
172
|
(!optionsArg.requireRelease || manifest.publishable), 'integrity', 'Artifact set does not match its required identity.');
|
|
173
|
+
const selected = bundleId === undefined ? manifest.artifacts : manifest.artifacts.filter((artifact) => artifact.id === bundleId);
|
|
174
|
+
requireValue(selected.length > 0, 'invalid_config', 'The requested bundle does not exist.');
|
|
154
175
|
const names = (await plugins.fs.readdir(directory)).sort();
|
|
155
|
-
requireValue(names.join('\n') === [...
|
|
176
|
+
requireValue(names.join('\n') === [...selected.map((artifact) => artifact.name), 'tspack-manifest.json', 'SHA256SUMS.txt'].sort().join('\n'),
|
|
156
177
|
'integrity', 'Artifact directory has an unexpected file set.');
|
|
157
178
|
const checksumPath = plugins.path.join(directory, 'SHA256SUMS.txt');
|
|
158
179
|
const checksumStat = await plugins.fs.lstat(checksumPath);
|
|
159
|
-
requireValue(checksumStat.isFile() && checksumStat.size <=
|
|
180
|
+
requireValue(checksumStat.isFile() && checksumStat.size <= 64 * 1024 &&
|
|
160
181
|
await plugins.fs.readFile(checksumPath, 'utf8') === this.checksums(manifest, manifestSha256), 'integrity', 'Artifact-set checksums disagree.');
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
182
|
+
return { directory, manifest, manifestSha256, reused: false };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
public async verify(directoryArg: string, optionsArg: ITsPackVerifyOptions = {}): Promise<ITsPackResult> {
|
|
186
|
+
const result = await this.readArtifactSet(directoryArg, optionsArg);
|
|
187
|
+
for (const artifact of result.manifest.artifacts) {
|
|
188
|
+
await this.verifyRecord(result.directory, result.manifest, artifact);
|
|
189
|
+
}
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private async verifyRecord(directory: string, manifest: ITsPackManifest, artifact: ITsPackArtifact, signal?: AbortSignal): Promise<void> {
|
|
194
|
+
const file = plugins.path.join(directory, artifact.name);
|
|
195
|
+
if (artifact.kind !== 'file') {
|
|
165
196
|
const { ['tspack.json']: _metadata, ...files } = artifact.files;
|
|
166
|
-
await verifyArchive(file, artifact, bundleMetadata(manifest, artifact.id, files));
|
|
197
|
+
await verifyArchive(file, artifact, bundleMetadata(manifest, artifact.id, files), { signal });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
signal?.throwIfAborted();
|
|
201
|
+
requireValue((await plugins.fs.lstat(file)).isFile(), 'integrity', 'Expected a regular standalone artifact.');
|
|
202
|
+
const handle = await plugins.fs.open(file, plugins.nodeFs.constants.O_RDONLY | plugins.nodeFs.constants.O_NOFOLLOW);
|
|
203
|
+
try {
|
|
204
|
+
const before = await handle.stat({ bigint: true });
|
|
205
|
+
requireValue(before.isFile() && before.size === BigInt(artifact.size), 'integrity', 'Standalone artifact size differs.');
|
|
206
|
+
const hash = plugins.crypto.createHash('sha256');
|
|
207
|
+
let size = 0;
|
|
208
|
+
for await (const chunk of handle.createReadStream({ autoClose: false })) {
|
|
209
|
+
signal?.throwIfAborted();
|
|
210
|
+
size += chunk.length;
|
|
211
|
+
requireValue(size <= artifact.size, 'integrity', 'Standalone artifact grew while reading.');
|
|
212
|
+
hash.update(chunk);
|
|
213
|
+
}
|
|
214
|
+
const after = await handle.stat({ bigint: true });
|
|
215
|
+
const current = await plugins.fs.lstat(file, { bigint: true });
|
|
216
|
+
requireValue(size === artifact.size && hash.digest('hex') === artifact.sha256 && current.isFile() &&
|
|
217
|
+
['dev', 'ino', 'size', 'mtimeNs', 'ctimeNs'].every((key) =>
|
|
218
|
+
before[key as keyof typeof before] === after[key as keyof typeof after] &&
|
|
219
|
+
before[key as keyof typeof before] === current[key as keyof typeof current]),
|
|
220
|
+
'integrity', 'Standalone artifact changed or does not match its identity.');
|
|
221
|
+
signal?.throwIfAborted();
|
|
222
|
+
} finally { await handle.close(); }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private async readBundle(directoryArg: string, bundleIdArg: string,
|
|
226
|
+
optionsArg: ITsPackBundleOptions): Promise<{ result: ITsPackBundleResult; signal?: AbortSignal }> {
|
|
227
|
+
object(optionsArg, ['expectedManifestSha256', 'expectedSourceCommit', 'requireRelease', 'signal']);
|
|
228
|
+
requireValue(isName(bundleIdArg) && isDigest(optionsArg.expectedManifestSha256) &&
|
|
229
|
+
(optionsArg.signal === undefined || optionsArg.signal instanceof AbortSignal),
|
|
230
|
+
'invalid_config', 'Bundle consumption requires a selected bundle and trusted manifest digest.');
|
|
231
|
+
const { signal, ...identity } = optionsArg;
|
|
232
|
+
signal?.throwIfAborted();
|
|
233
|
+
const result = await this.readArtifactSet(directoryArg, identity, bundleIdArg);
|
|
234
|
+
signal?.throwIfAborted();
|
|
235
|
+
return { result: { ...result, artifact: result.manifest.artifacts.find((artifact) => artifact.id === bundleIdArg)! }, signal };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Verify a selected-only download: complete metadata plus exactly one archive. */
|
|
239
|
+
public async verifyBundle(directoryArg: string, bundleIdArg: string,
|
|
240
|
+
optionsArg: ITsPackBundleOptions): Promise<ITsPackBundleResult> {
|
|
241
|
+
const { result, signal } = await this.readBundle(directoryArg, bundleIdArg, optionsArg);
|
|
242
|
+
const { artifact } = result;
|
|
243
|
+
requireValue(artifact.kind !== 'file', 'invalid_config', 'Selected artifact is a standalone file, not an archive bundle.');
|
|
244
|
+
await this.verifyRecord(result.directory, result.manifest, artifact, signal);
|
|
245
|
+
signal?.throwIfAborted();
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Verify one selected archive or standalone file against the complete trusted release identity. */
|
|
250
|
+
public async verifyArtifact(directoryArg: string, artifactIdArg: string,
|
|
251
|
+
optionsArg: ITsPackBundleOptions): Promise<ITsPackBundleResult> {
|
|
252
|
+
const { result, signal } = await this.readBundle(directoryArg, artifactIdArg, optionsArg);
|
|
253
|
+
await this.verifyRecord(result.directory, result.manifest, result.artifact, signal);
|
|
254
|
+
signal?.throwIfAborted();
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Extract code/assets into a fresh private directory. The caller owns installation and activation. */
|
|
259
|
+
public async extractBundle(directoryArg: string, bundleIdArg: string, parentArg: string,
|
|
260
|
+
optionsArg: ITsPackBundleOptions): Promise<ITsPackExtractResult> {
|
|
261
|
+
const { result, signal } = await this.readBundle(directoryArg, bundleIdArg, optionsArg);
|
|
262
|
+
requireValue(result.artifact.kind !== 'file', 'invalid_config', 'Selected artifact is a standalone file, not an archive bundle.');
|
|
263
|
+
requireValue(typeof parentArg === 'string' && parentArg.length > 0, 'unsafe_input', 'An extraction parent is required.');
|
|
264
|
+
const parent = plugins.path.resolve(this.cwd, parentArg);
|
|
265
|
+
requireValue((await plugins.fs.lstat(parent)).isDirectory() && await plugins.fs.realpath(parent) === parent,
|
|
266
|
+
'unsafe_input', 'Extraction requires an existing canonical parent directory.');
|
|
267
|
+
signal?.throwIfAborted();
|
|
268
|
+
const directory = await plugins.fs.mkdtemp(plugins.path.join(parent, '.tspack-extract-'));
|
|
269
|
+
const owner = await plugins.fs.lstat(directory, { bigint: true });
|
|
270
|
+
try {
|
|
271
|
+
const { artifact } = result;
|
|
272
|
+
const { ['tspack.json']: _metadata, ...files } = artifact.files;
|
|
273
|
+
await verifyArchive(plugins.path.join(result.directory, artifact.name), artifact,
|
|
274
|
+
bundleMetadata(result.manifest, artifact.id, files), { destination: directory, signal });
|
|
275
|
+
signal?.throwIfAborted();
|
|
276
|
+
return { ...result, sourceDirectory: result.directory, directory };
|
|
277
|
+
} catch (error) {
|
|
278
|
+
const current = await plugins.fs.lstat(directory, { bigint: true });
|
|
279
|
+
requireValue(current.isDirectory() && current.dev === owner.dev && current.ino === owner.ino &&
|
|
280
|
+
await plugins.fs.realpath(directory) === directory, 'unsafe_input', 'Extraction directory ownership changed; cleanup refused.');
|
|
281
|
+
await plugins.fs.rm(directory, { recursive: true });
|
|
282
|
+
throw error;
|
|
167
283
|
}
|
|
168
|
-
return { directory, manifest, manifestSha256, reused: false };
|
|
169
284
|
}
|
|
170
285
|
}
|
package/ts/interfaces.ts
CHANGED
|
@@ -13,12 +13,22 @@ export interface ITsPackBundle {
|
|
|
13
13
|
files: ITsPackFile[];
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** A named release asset whose bytes are published without an archive wrapper. */
|
|
17
|
+
export interface ITsPackAsset {
|
|
18
|
+
id: string;
|
|
19
|
+
source: string;
|
|
20
|
+
name: string;
|
|
21
|
+
executable?: boolean;
|
|
22
|
+
sha256?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
export interface ITsPackConfig {
|
|
17
26
|
schemaVersion: 1;
|
|
18
27
|
/** Defaults to the package name with its scope separator replaced by a hyphen. */
|
|
19
28
|
name?: string;
|
|
20
29
|
outputDirectory?: string;
|
|
21
30
|
bundles: ITsPackBundle[];
|
|
31
|
+
assets?: ITsPackAsset[];
|
|
22
32
|
}
|
|
23
33
|
|
|
24
34
|
export interface ITsPackOptions {
|
|
@@ -35,6 +45,8 @@ export interface ITsPackFileRecord {
|
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
export interface ITsPackArtifact {
|
|
48
|
+
/** Omitted for v1-compatible tar.gz bundles; explicit for standalone files in v2 sets. */
|
|
49
|
+
kind?: 'file';
|
|
38
50
|
id: string;
|
|
39
51
|
name: string;
|
|
40
52
|
sha256: string;
|
|
@@ -43,7 +55,7 @@ export interface ITsPackArtifact {
|
|
|
43
55
|
}
|
|
44
56
|
|
|
45
57
|
export interface ITsPackManifest {
|
|
46
|
-
format: 'tspack.release.v1';
|
|
58
|
+
format: 'tspack.release.v1' | 'tspack.release.v2';
|
|
47
59
|
packageName: string;
|
|
48
60
|
name: string;
|
|
49
61
|
version: string;
|
|
@@ -66,3 +78,18 @@ export interface ITsPackVerifyOptions {
|
|
|
66
78
|
expectedSourceCommit?: string;
|
|
67
79
|
requireRelease?: boolean;
|
|
68
80
|
}
|
|
81
|
+
|
|
82
|
+
export interface ITsPackBundleOptions extends ITsPackVerifyOptions {
|
|
83
|
+
/** The caller's trusted digest of the complete release manifest. */
|
|
84
|
+
expectedManifestSha256: string;
|
|
85
|
+
signal?: AbortSignal;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ITsPackBundleResult extends ITsPackResult {
|
|
89
|
+
artifact: ITsPackArtifact;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ITsPackExtractResult extends ITsPackBundleResult {
|
|
93
|
+
/** Input artifact directory. `directory` is the new private extraction directory. */
|
|
94
|
+
sourceDirectory: string;
|
|
95
|
+
}
|
package/ts/tspack.cli.ts
CHANGED
|
@@ -36,7 +36,9 @@ export async function runCli(args = process.argv.slice(2)): Promise<void> {
|
|
|
36
36
|
? await pack.verify(directory, { requireRelease: release, expectedManifestSha256 })
|
|
37
37
|
: (() => { throw new TsPackError('arguments', 'Expected pack or verify with its required arguments.'); })();
|
|
38
38
|
console.log(JSON.stringify({ directory: result.directory, manifestSha256: result.manifestSha256,
|
|
39
|
-
publishable: result.manifest.publishable, reused: result.reused,
|
|
39
|
+
publishable: result.manifest.publishable, reused: result.reused,
|
|
40
|
+
bundles: result.manifest.artifacts.filter((artifact) => artifact.kind !== 'file').length,
|
|
41
|
+
assets: result.manifest.artifacts.filter((artifact) => artifact.kind === 'file').length }));
|
|
40
42
|
} catch (error) {
|
|
41
43
|
console.error(error instanceof TsPackError ? `tspack: ${error.code}: ${error.message}` : 'tspack: operation failed.');
|
|
42
44
|
process.exitCode = 1;
|
package/ts/validation.ts
CHANGED
|
@@ -18,6 +18,8 @@ export const isName = (value: unknown): value is string => typeof value === 'str
|
|
|
18
18
|
export const isVersion = (value: unknown): value is string => typeof value === 'string' && value.length <= 64 && /^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(value);
|
|
19
19
|
export const isPath = (value: unknown): value is string => typeof value === 'string' && value.length <= 240 &&
|
|
20
20
|
/^[a-zA-Z0-9@_+./-]+$/.test(value) && !value.startsWith('/') && value.split('/').every((part) => part && part !== '.' && part !== '..');
|
|
21
|
+
export const isAssetName = (value: unknown): value is string => isPath(value) && !value.includes('/') &&
|
|
22
|
+
!['tspack-manifest.json', 'sha256sums.txt', 'tspack.json'].includes(value.toLowerCase());
|
|
21
23
|
export const jsonBytes = (value: unknown) => Buffer.from(JSON.stringify(value, null, 2) + '\n');
|
|
22
24
|
|
|
23
25
|
export function object(value: unknown, allowed?: readonly string[]): asserts value is Record<string, unknown> {
|
|
@@ -28,13 +30,25 @@ export function object(value: unknown, allowed?: readonly string[]): asserts val
|
|
|
28
30
|
'value' in Object.getOwnPropertyDescriptor(value, key)!), 'invalid_config', 'Unexpected record property.');
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
export function normalizeConfig(value: unknown, packageName: string): Required<ITsPackConfig> {
|
|
32
|
-
object(value, ['schemaVersion', 'name', 'outputDirectory', 'bundles']);
|
|
33
|
+
export function normalizeConfig(value: unknown, packageName: string): Omit<Required<ITsPackConfig>, 'assets'> & Pick<ITsPackConfig, 'assets'> {
|
|
34
|
+
object(value, ['schemaVersion', 'name', 'outputDirectory', 'bundles', 'assets']);
|
|
33
35
|
const name = value.name ?? packageName.replace(/^@/, '').replace('/', '-');
|
|
34
36
|
const outputDirectory = value.outputDirectory ?? 'dist_pack';
|
|
35
37
|
requireValue(value.schemaVersion === 1 && isName(name) && isPath(outputDirectory) &&
|
|
36
|
-
Array.isArray(value.bundles) && value.bundles.length
|
|
38
|
+
Array.isArray(value.bundles) && value.bundles.length <= 128 &&
|
|
39
|
+
(!Object.hasOwn(value, 'assets') || Array.isArray(value.assets)),
|
|
37
40
|
'invalid_config', 'Invalid tspack configuration.');
|
|
41
|
+
const assetInputs = (value.assets ?? []) as unknown[];
|
|
42
|
+
requireValue(assetInputs.length + value.bundles.length > 0 && assetInputs.length + value.bundles.length <= 128,
|
|
43
|
+
'invalid_config', 'A release requires between one and 128 artifacts.');
|
|
44
|
+
const assets = assetInputs.map((asset) => {
|
|
45
|
+
object(asset, ['id', 'source', 'name', 'executable', 'sha256']);
|
|
46
|
+
requireValue(isName(asset.id) && isPath(asset.source) && isAssetName(asset.name) &&
|
|
47
|
+
(!Object.hasOwn(asset, 'executable') || typeof asset.executable === 'boolean') &&
|
|
48
|
+
(!Object.hasOwn(asset, 'sha256') || isDigest(asset.sha256)), 'invalid_config', 'Invalid standalone asset.');
|
|
49
|
+
return { id: asset.id, source: asset.source, name: asset.name, executable: asset.executable === true,
|
|
50
|
+
...(asset.sha256 ? { sha256: asset.sha256 as string } : {}) };
|
|
51
|
+
}).sort((a, b) => a.id.localeCompare(b.id, 'en'));
|
|
38
52
|
let count = 0;
|
|
39
53
|
const bundles = value.bundles.map((bundle: unknown) => {
|
|
40
54
|
object(bundle, ['id', 'files']);
|
|
@@ -54,9 +68,12 @@ export function normalizeConfig(value: unknown, packageName: string): Required<I
|
|
|
54
68
|
'invalid_config', 'Bundle paths collide.');
|
|
55
69
|
return { id: bundle.id, files };
|
|
56
70
|
}).sort((a, b) => a.id.localeCompare(b.id, 'en'));
|
|
57
|
-
|
|
71
|
+
const ids = [...bundles, ...assets].map((artifact) => artifact.id);
|
|
72
|
+
requireValue(count + assets.length <= limits.files && new Set(ids).size === ids.length &&
|
|
73
|
+
new Set(assets.map((asset) => asset.name.toLowerCase())).size === assets.length,
|
|
58
74
|
'invalid_config', 'Too many files or duplicate bundle identifiers.');
|
|
59
|
-
|
|
75
|
+
// Omit an empty extension so archive-only v1 configuration hashes remain unchanged.
|
|
76
|
+
return { schemaVersion: 1, name, outputDirectory, bundles, ...(assets.length ? { assets } : {}) };
|
|
60
77
|
}
|
|
61
78
|
|
|
62
79
|
export function validateFileRecord(value: unknown): asserts value is ITsPackFileRecord {
|
|
@@ -68,7 +85,7 @@ export function validateFileRecord(value: unknown): asserts value is ITsPackFile
|
|
|
68
85
|
|
|
69
86
|
export function validateManifest(value: unknown): asserts value is ITsPackManifest {
|
|
70
87
|
object(value, ['format', 'packageName', 'name', 'version', 'sourceCommit', 'sourceDirty', 'publishable', 'configurationSha256', 'artifacts']);
|
|
71
|
-
requireValue(
|
|
88
|
+
requireValue(['tspack.release.v1', 'tspack.release.v2'].includes(value.format as string) && typeof value.packageName === 'string' && value.packageName.length <= 214 &&
|
|
72
89
|
isName(value.name) && isVersion(value.version) && isCommit(value.sourceCommit) &&
|
|
73
90
|
typeof value.sourceDirty === 'boolean' && typeof value.publishable === 'boolean' &&
|
|
74
91
|
(!value.publishable || !value.sourceDirty) && isDigest(value.configurationSha256) &&
|
|
@@ -78,22 +95,42 @@ export function validateManifest(value: unknown): asserts value is ITsPackManife
|
|
|
78
95
|
const names = new Set();
|
|
79
96
|
let total = 0;
|
|
80
97
|
let count = 0;
|
|
98
|
+
let standaloneCount = 0;
|
|
81
99
|
for (const artifact of value.artifacts) {
|
|
82
|
-
object(artifact, ['id', 'name', 'sha256', 'size', 'files']);
|
|
100
|
+
object(artifact, ['id', 'name', 'sha256', 'size', 'files', ...(value.format === 'tspack.release.v2' ? ['kind'] : [])]);
|
|
101
|
+
requireValue(!Object.hasOwn(artifact, 'kind') || artifact.kind === 'file', 'invalid_manifest', 'Invalid artifact kind.');
|
|
102
|
+
const standalone = artifact.kind === 'file';
|
|
83
103
|
requireValue(isName(artifact.id) && !ids.has(artifact.id) &&
|
|
84
|
-
isPath(artifact.name) && artifact.name === `${value.name}-${value.version}-${artifact.id}.tar.gz`
|
|
104
|
+
isPath(artifact.name) && (standalone ? isAssetName(artifact.name) : artifact.name === `${value.name}-${value.version}-${artifact.id}.tar.gz`) &&
|
|
105
|
+
!names.has(artifact.name.toLowerCase()) &&
|
|
85
106
|
isDigest(artifact.sha256) && typeof artifact.size === 'number' && Number.isSafeInteger(artifact.size) &&
|
|
86
|
-
artifact.size
|
|
87
|
-
|
|
107
|
+
(standalone ? artifact.size >= 0 && artifact.size <= limits.file : artifact.size > 0 && artifact.size <= limits.total),
|
|
108
|
+
'invalid_manifest', 'Invalid artifact identity.');
|
|
109
|
+
ids.add(artifact.id); names.add(artifact.name.toLowerCase());
|
|
88
110
|
object(artifact.files);
|
|
89
|
-
|
|
111
|
+
const fileRecords = artifact.files;
|
|
112
|
+
const paths = Object.keys(fileRecords);
|
|
113
|
+
if (standalone) {
|
|
114
|
+
standaloneCount++;
|
|
115
|
+
requireValue(paths.length === 1 && paths[0] === artifact.name, 'invalid_manifest', 'A standalone asset requires its exact file record.');
|
|
116
|
+
const record = fileRecords[artifact.name];
|
|
117
|
+
validateFileRecord(record);
|
|
118
|
+
requireValue(record.sha256 === artifact.sha256 && record.size === artifact.size, 'invalid_manifest', 'Standalone file identities disagree.');
|
|
119
|
+
total += record.size; count++;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
requireValue(Object.hasOwn(artifact.files, 'tspack.json') && paths.length > 1,
|
|
90
123
|
'invalid_manifest', 'Bundle metadata or selected files are missing.');
|
|
124
|
+
requireValue(paths.every((name) => name.split('/').slice(0, -1).every((_part, index, parents) =>
|
|
125
|
+
!Object.hasOwn(fileRecords, parents.slice(0, index + 1).join('/')))),
|
|
126
|
+
'invalid_manifest', 'Archive member paths collide.');
|
|
91
127
|
for (const [name, record] of Object.entries(artifact.files)) {
|
|
92
128
|
requireValue(isPath(name), 'invalid_manifest', 'Invalid archive member path.');
|
|
93
129
|
validateFileRecord(record);
|
|
94
130
|
total += record.size; count++;
|
|
95
131
|
}
|
|
96
132
|
}
|
|
133
|
+
requireValue(value.format === 'tspack.release.v1' || standaloneCount > 0, 'invalid_manifest', 'Manifest v2 requires a standalone asset.');
|
|
97
134
|
requireValue(total <= limits.total && count <= limits.files + 128, 'invalid_manifest', 'Artifact set exceeds its limits.');
|
|
98
135
|
}
|
|
99
136
|
|
package/readme.plan.md
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# tspack implementation
|
|
2
|
-
|
|
3
|
-
Create the shared owner for packaging existing executables and supporting assets.
|
|
4
|
-
TsDeno and TsRust retain compilation; GitZone and CI retain publication. Pallet
|
|
5
|
-
will consume the published tool instead of maintaining its own archive scripts.
|
|
6
|
-
|
|
7
|
-
- Expose a typed TsPack API and `tspack pack` / `tspack verify` CLI, configured
|
|
8
|
-
under `@git.zone/tspack` in `.smartconfig.json`.
|
|
9
|
-
- Package explicit file sets into deterministic streaming tar.gz bundles using
|
|
10
|
-
SmartArchive's public streaming API. Preserve executable roles, hash every file,
|
|
11
|
-
reject unsafe paths and changed inputs, and publish output directories atomically.
|
|
12
|
-
- Record package/version, Git source identity, configuration identity, bundle
|
|
13
|
-
contents and archive hashes in a sealed artifact set. Release mode requires a
|
|
14
|
-
clean matching tag; development artifacts remain explicitly unpublishable.
|
|
15
|
-
- Verify both archive bytes and every declared member without extracting paths.
|
|
16
|
-
Reuse a retained sealed release set only after verification; never substitute
|
|
17
|
-
freshly rebuilt bytes into an existing immutable release.
|
|
18
|
-
- Include only explicitly selected assets/notices. Do not automatically disclose
|
|
19
|
-
application source, vendor dependency trees or rebuild runtimes for LGPL.
|
|
20
|
-
- Test real archives, deterministic repacking, corruption, unsafe paths, closed
|
|
21
|
-
file sets, stale/dirty release identities and retained-output reuse. Build and
|
|
22
|
-
type-check directly, run stable checker gates, release the tool, then consume it.
|
|
23
|
-
|
|
24
|
-
Global policy now accepts LGPL; its specific obligations remain separate from
|
|
25
|
-
license acceptance. The previous Pallet runtime-reconstruction experiment was
|
|
26
|
-
stopped and its uncommitted distribution prototype removed from active source.
|