@git.zone/tspack 1.1.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/.smartconfig.json +26 -0
- package/cli.js +3 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/archive.d.ts +3 -0
- package/dist_ts/archive.js +60 -0
- package/dist_ts/classes.tspack.d.ts +11 -0
- package/dist_ts/classes.tspack.js +181 -0
- package/dist_ts/index.d.ts +3 -0
- package/dist_ts/index.js +4 -0
- package/dist_ts/interfaces.d.ts +60 -0
- package/dist_ts/interfaces.js +2 -0
- package/dist_ts/plugins.d.ts +13 -0
- package/dist_ts/plugins.js +16 -0
- package/dist_ts/tspack.cli.d.ts +1 -0
- package/dist_ts/tspack.cli.js +53 -0
- package/dist_ts/validation.d.ts +32 -0
- package/dist_ts/validation.js +132 -0
- package/license.md +21 -0
- package/package.json +49 -0
- package/readme.md +181 -0
- package/readme.plan.md +26 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/archive.ts +60 -0
- package/ts/classes.tspack.ts +170 -0
- package/ts/index.ts +3 -0
- package/ts/interfaces.ts +68 -0
- package/ts/plugins.ts +16 -0
- package/ts/tspack.cli.ts +44 -0
- package/ts/validation.ts +136 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import type { ITsPackConfig, ITsPackOptions, ITsPackResult, ITsPackVerifyOptions, ITsPackFileRecord, ITsPackManifest } from './interfaces.js';
|
|
3
|
+
import { packArchive, verifyArchive } from './archive.js';
|
|
4
|
+
import { digestFile, isCommit, isDigest, isPath, isVersion, jsonBytes, limits, noSymlinks, normalizeConfig, object,
|
|
5
|
+
readJson, requireValue, sha256, validateManifest } from './validation.js';
|
|
6
|
+
export { TsPackError } from './validation.js';
|
|
7
|
+
|
|
8
|
+
const exec = plugins.util.promisify(plugins.childProcess.execFile);
|
|
9
|
+
const bundleMetadata = (manifest: Omit<ITsPackManifest, 'artifacts'>, id: string, files: Record<string, ITsPackFileRecord>) => ({
|
|
10
|
+
format: 'tspack.bundle.v1', packageName: manifest.packageName, version: manifest.version,
|
|
11
|
+
id, sourceCommit: manifest.sourceCommit, sourceDirty: manifest.sourceDirty,
|
|
12
|
+
publishable: manifest.publishable, configurationSha256: manifest.configurationSha256, files,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** Packages explicit code/assets. Compilation, publishing and runtime installation have separate owners. */
|
|
16
|
+
export class TsPack {
|
|
17
|
+
constructor(private readonly cwd = process.cwd()) {}
|
|
18
|
+
|
|
19
|
+
private async source(root: string, version: string, release: boolean) {
|
|
20
|
+
const run = async (...args: string[]) => (await exec('git', args, { cwd: root, maxBuffer: 4 * 1024 ** 2 })).stdout.trim();
|
|
21
|
+
const commit = await run('rev-parse', 'HEAD');
|
|
22
|
+
requireValue(isCommit(commit), 'source_state', 'A committed Git source identity is required.');
|
|
23
|
+
const dirty = (await run('status', '--porcelain')).length !== 0;
|
|
24
|
+
if (release) {
|
|
25
|
+
requireValue(!dirty && await run('rev-parse', `refs/tags/v${version}^{commit}`) === commit,
|
|
26
|
+
'source_state', 'Release packaging requires clean source at its matching version tag.');
|
|
27
|
+
}
|
|
28
|
+
return { commit, dirty };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public async pack(configArg?: ITsPackConfig, optionsArg: ITsPackOptions = {}): Promise<ITsPackResult> {
|
|
32
|
+
object(optionsArg, ['release', 'reuse']);
|
|
33
|
+
requireValue((optionsArg.release === undefined || typeof optionsArg.release === 'boolean') &&
|
|
34
|
+
(optionsArg.reuse === undefined || typeof optionsArg.reuse === 'boolean') &&
|
|
35
|
+
(!optionsArg.reuse || optionsArg.release), 'invalid_config', 'Reuse requires release mode.');
|
|
36
|
+
const root = await plugins.fs.realpath(this.cwd);
|
|
37
|
+
const { value: packageJson } = await readJson(plugins.path.join(root, 'package.json'));
|
|
38
|
+
object(packageJson);
|
|
39
|
+
requireValue(typeof packageJson.name === 'string' && packageJson.name.length <= 214 && isVersion(packageJson.version),
|
|
40
|
+
'invalid_config', 'Package metadata requires a name and version.');
|
|
41
|
+
const config = normalizeConfig(configArg ?? new plugins.smartconfig.Smartconfig(root).dataFor('@git.zone/tspack', {}), packageJson.name);
|
|
42
|
+
requireValue(config.bundles.every((bundle) => isPath(`${config.name}-${packageJson.version}-${bundle.id}.tar.gz`)),
|
|
43
|
+
'invalid_config', 'Archive names exceed the portable path limit.');
|
|
44
|
+
const configurationSha256 = sha256(jsonBytes(config));
|
|
45
|
+
const source = await this.source(root, packageJson.version, optionsArg.release === true);
|
|
46
|
+
const outputRoot = await noSymlinks(root, config.outputDirectory, true);
|
|
47
|
+
const releaseName = `${config.name}-${packageJson.version}-${source.commit.slice(0, 12)}`;
|
|
48
|
+
if (optionsArg.reuse) {
|
|
49
|
+
const directory = plugins.path.join(outputRoot, releaseName);
|
|
50
|
+
let exists = true;
|
|
51
|
+
try { await plugins.fs.lstat(directory); }
|
|
52
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') exists = false; else throw error; }
|
|
53
|
+
if (exists) {
|
|
54
|
+
const result = await this.verify(directory, { requireRelease: true, expectedSourceCommit: source.commit });
|
|
55
|
+
requireValue(result.manifest.configurationSha256 === configurationSha256 && result.manifest.packageName === packageJson.name &&
|
|
56
|
+
result.manifest.version === packageJson.version, 'integrity', 'Retained release set belongs to another configuration.');
|
|
57
|
+
return { ...result, reused: true };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
await plugins.fs.mkdir(outputRoot, { recursive: true });
|
|
61
|
+
const stage = await plugins.fs.mkdtemp(plugins.path.join(outputRoot, '.tspack-'));
|
|
62
|
+
const assets = plugins.path.join(stage, 'assets');
|
|
63
|
+
await plugins.fs.mkdir(assets);
|
|
64
|
+
await plugins.fs.mkdir(plugins.path.join(stage, 'bundles'));
|
|
65
|
+
try {
|
|
66
|
+
const manifest: ITsPackManifest = {
|
|
67
|
+
format: 'tspack.release.v1', packageName: packageJson.name, name: config.name, version: packageJson.version,
|
|
68
|
+
sourceCommit: source.commit, sourceDirty: source.dirty, publishable: optionsArg.release === true,
|
|
69
|
+
configurationSha256, artifacts: [],
|
|
70
|
+
};
|
|
71
|
+
let total = 0;
|
|
72
|
+
for (const bundle of config.bundles) {
|
|
73
|
+
const directory = plugins.path.join(stage, 'bundles', bundle.id);
|
|
74
|
+
await plugins.fs.mkdir(directory);
|
|
75
|
+
const files: Record<string, ITsPackFileRecord> = Object.create(null);
|
|
76
|
+
for (const file of bundle.files) {
|
|
77
|
+
const input = await noSymlinks(root, file.source);
|
|
78
|
+
const handle = await plugins.fs.open(input, plugins.nodeFs.constants.O_RDONLY | plugins.nodeFs.constants.O_NOFOLLOW);
|
|
79
|
+
try {
|
|
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(); }
|
|
105
|
+
}
|
|
106
|
+
const metadata = jsonBytes(bundleMetadata(manifest, bundle.id, files));
|
|
107
|
+
requireValue(metadata.length <= limits.json, 'invalid_config', 'Bundle metadata exceeds its limit.');
|
|
108
|
+
await plugins.fs.writeFile(plugins.path.join(directory, 'tspack.json'), metadata, { flag: 'wx' });
|
|
109
|
+
files['tspack.json'] = { sha256: sha256(metadata), size: metadata.length, mode: 0o644 };
|
|
110
|
+
const name = `${config.name}-${packageJson.version}-${bundle.id}.tar.gz`;
|
|
111
|
+
await packArchive(directory, plugins.path.join(assets, name), files);
|
|
112
|
+
manifest.artifacts.push({ id: bundle.id, name, ...await digestFile(plugins.path.join(assets, name)), files });
|
|
113
|
+
}
|
|
114
|
+
const finalSource = await this.source(root, packageJson.version, optionsArg.release === true);
|
|
115
|
+
requireValue(finalSource.commit === source.commit && finalSource.dirty === source.dirty, 'source_state', 'Source identity changed while packaging.');
|
|
116
|
+
validateManifest(manifest);
|
|
117
|
+
const metadata = jsonBytes(manifest);
|
|
118
|
+
const manifestSha256 = sha256(metadata);
|
|
119
|
+
await plugins.fs.writeFile(plugins.path.join(assets, 'tspack-manifest.json'), metadata, { flag: 'wx' });
|
|
120
|
+
await plugins.fs.writeFile(plugins.path.join(assets, 'SHA256SUMS.txt'), this.checksums(manifest, manifestSha256), { flag: 'wx' });
|
|
121
|
+
await this.verify(assets, { expectedManifestSha256: manifestSha256 });
|
|
122
|
+
const name = optionsArg.release ? releaseName : `${releaseName}-qualification-${manifestSha256.slice(0, 16)}`;
|
|
123
|
+
const output = plugins.path.join(outputRoot, name);
|
|
124
|
+
let exists = true;
|
|
125
|
+
try { await plugins.fs.lstat(output); }
|
|
126
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') exists = false; else throw error; }
|
|
127
|
+
requireValue(!exists, 'exists', 'A sealed set already occupies this output; use verified release reuse or a separate output directory.');
|
|
128
|
+
await plugins.fs.rename(assets, output);
|
|
129
|
+
return { directory: output, manifest, manifestSha256, reused: false };
|
|
130
|
+
} finally { await plugins.fs.rm(stage, { recursive: true, force: true }); }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private checksums(manifest: ITsPackManifest, manifestSha256: string): string {
|
|
134
|
+
return [...manifest.artifacts.map((artifact) => [artifact.name, artifact.sha256]), ['tspack-manifest.json', manifestSha256]]
|
|
135
|
+
.sort(([a], [b]) => a!.localeCompare(b!, 'en')).map(([name, hash]) => `${hash} ${name}`).join('\n') + '\n';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
public async verify(directoryArg: string, optionsArg: ITsPackVerifyOptions = {}): Promise<ITsPackResult> {
|
|
139
|
+
object(optionsArg, ['expectedManifestSha256', 'expectedSourceCommit', 'requireRelease']);
|
|
140
|
+
requireValue((optionsArg.expectedManifestSha256 === undefined || isDigest(optionsArg.expectedManifestSha256)) &&
|
|
141
|
+
(optionsArg.expectedSourceCommit === undefined || isCommit(optionsArg.expectedSourceCommit)) &&
|
|
142
|
+
(optionsArg.requireRelease === undefined || typeof optionsArg.requireRelease === 'boolean'),
|
|
143
|
+
'invalid_config', 'Invalid verification identity.');
|
|
144
|
+
const directory = plugins.path.resolve(this.cwd, directoryArg);
|
|
145
|
+
requireValue((await plugins.fs.lstat(directory)).isDirectory() && await plugins.fs.realpath(directory) === directory,
|
|
146
|
+
'unsafe_input', 'A canonical regular artifact directory is required.');
|
|
147
|
+
const { value, bytes } = await readJson(plugins.path.join(directory, 'tspack-manifest.json'));
|
|
148
|
+
const manifestSha256 = sha256(bytes);
|
|
149
|
+
validateManifest(value);
|
|
150
|
+
const manifest = value;
|
|
151
|
+
requireValue((!optionsArg.expectedManifestSha256 || optionsArg.expectedManifestSha256 === manifestSha256) &&
|
|
152
|
+
(!optionsArg.expectedSourceCommit || optionsArg.expectedSourceCommit === manifest.sourceCommit) &&
|
|
153
|
+
(!optionsArg.requireRelease || manifest.publishable), 'integrity', 'Artifact set does not match its required identity.');
|
|
154
|
+
const names = (await plugins.fs.readdir(directory)).sort();
|
|
155
|
+
requireValue(names.join('\n') === [...manifest.artifacts.map((artifact) => artifact.name), 'tspack-manifest.json', 'SHA256SUMS.txt'].sort().join('\n'),
|
|
156
|
+
'integrity', 'Artifact directory has an unexpected file set.');
|
|
157
|
+
const checksumPath = plugins.path.join(directory, 'SHA256SUMS.txt');
|
|
158
|
+
const checksumStat = await plugins.fs.lstat(checksumPath);
|
|
159
|
+
requireValue(checksumStat.isFile() && checksumStat.size <= 32 * 1024 &&
|
|
160
|
+
await plugins.fs.readFile(checksumPath, 'utf8') === this.checksums(manifest, manifestSha256), 'integrity', 'Artifact-set checksums disagree.');
|
|
161
|
+
for (const artifact of manifest.artifacts) {
|
|
162
|
+
const file = plugins.path.join(directory, artifact.name);
|
|
163
|
+
const actual = await digestFile(file);
|
|
164
|
+
requireValue(actual.size === artifact.size && actual.sha256 === artifact.sha256, 'integrity', 'Archive integrity mismatch.');
|
|
165
|
+
const { ['tspack.json']: _metadata, ...files } = artifact.files;
|
|
166
|
+
await verifyArchive(file, artifact, bundleMetadata(manifest, artifact.id, files));
|
|
167
|
+
}
|
|
168
|
+
return { directory, manifest, manifestSha256, reused: false };
|
|
169
|
+
}
|
|
170
|
+
}
|
package/ts/index.ts
ADDED
package/ts/interfaces.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export interface ITsPackFile {
|
|
2
|
+
/** Project-relative regular input file. Ancestor symlinks are rejected. */
|
|
3
|
+
source: string;
|
|
4
|
+
/** Portable relative name inside the bundle. */
|
|
5
|
+
path: string;
|
|
6
|
+
executable?: boolean;
|
|
7
|
+
/** Optional upstream identity, verified before assembly. */
|
|
8
|
+
sha256?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ITsPackBundle {
|
|
12
|
+
id: string;
|
|
13
|
+
files: ITsPackFile[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ITsPackConfig {
|
|
17
|
+
schemaVersion: 1;
|
|
18
|
+
/** Defaults to the package name with its scope separator replaced by a hyphen. */
|
|
19
|
+
name?: string;
|
|
20
|
+
outputDirectory?: string;
|
|
21
|
+
bundles: ITsPackBundle[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ITsPackOptions {
|
|
25
|
+
/** Requires clean source at the package's vVERSION tag. */
|
|
26
|
+
release?: boolean;
|
|
27
|
+
/** Reuse a verified existing sealed set for this exact tagged source/configuration. */
|
|
28
|
+
reuse?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ITsPackFileRecord {
|
|
32
|
+
sha256: string;
|
|
33
|
+
size: number;
|
|
34
|
+
mode: 420 | 493;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ITsPackArtifact {
|
|
38
|
+
id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
sha256: string;
|
|
41
|
+
size: number;
|
|
42
|
+
files: Record<string, ITsPackFileRecord>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ITsPackManifest {
|
|
46
|
+
format: 'tspack.release.v1';
|
|
47
|
+
packageName: string;
|
|
48
|
+
name: string;
|
|
49
|
+
version: string;
|
|
50
|
+
sourceCommit: string;
|
|
51
|
+
sourceDirty: boolean;
|
|
52
|
+
publishable: boolean;
|
|
53
|
+
configurationSha256: string;
|
|
54
|
+
artifacts: ITsPackArtifact[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ITsPackResult {
|
|
58
|
+
directory: string;
|
|
59
|
+
manifest: ITsPackManifest;
|
|
60
|
+
manifestSha256: string;
|
|
61
|
+
reused: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ITsPackVerifyOptions {
|
|
65
|
+
expectedManifestSha256?: string;
|
|
66
|
+
expectedSourceCommit?: string;
|
|
67
|
+
requireRelease?: boolean;
|
|
68
|
+
}
|
package/ts/plugins.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Node native modules
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import * as nodeFs from 'node:fs';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import * as crypto from 'node:crypto';
|
|
6
|
+
import * as stream from 'node:stream';
|
|
7
|
+
import * as streamPromises from 'node:stream/promises';
|
|
8
|
+
import * as zlib from 'node:zlib';
|
|
9
|
+
import * as childProcess from 'node:child_process';
|
|
10
|
+
import * as util from 'node:util';
|
|
11
|
+
export { fs, nodeFs, path, crypto, stream, streamPromises, zlib, childProcess, util };
|
|
12
|
+
|
|
13
|
+
// @push.rocks modules
|
|
14
|
+
import * as smartarchive from '@push.rocks/smartarchive';
|
|
15
|
+
import * as smartconfig from '@push.rocks/smartconfig';
|
|
16
|
+
export { smartarchive, smartconfig };
|
package/ts/tspack.cli.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { TsPack, TsPackError } from './classes.tspack.js';
|
|
2
|
+
import * as plugins from './plugins.js';
|
|
3
|
+
import type { ITsPackConfig } from './interfaces.js';
|
|
4
|
+
import { readJson } from './validation.js';
|
|
5
|
+
|
|
6
|
+
export async function runCli(args = process.argv.slice(2)): Promise<void> {
|
|
7
|
+
try {
|
|
8
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === 'help') {
|
|
9
|
+
console.log('tspack pack [--release] [--reuse] [--config <json-file>]\ntspack verify <artifact-directory> [--release] [--sha256 <manifest-digest>]');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const command = args[0];
|
|
13
|
+
const values = args.slice(1);
|
|
14
|
+
const directory = command === 'verify' ? values.shift() : undefined;
|
|
15
|
+
let release = false;
|
|
16
|
+
let reuse = false;
|
|
17
|
+
let configFile: string | undefined;
|
|
18
|
+
let expectedManifestSha256: string | undefined;
|
|
19
|
+
const used = new Set();
|
|
20
|
+
while (values.length) {
|
|
21
|
+
const option = values.shift()!;
|
|
22
|
+
if (used.has(option)) throw new TsPackError('arguments', 'Duplicate command option.');
|
|
23
|
+
used.add(option);
|
|
24
|
+
if (option === '--release') release = true;
|
|
25
|
+
else if (option === '--reuse' && command === 'pack') reuse = true;
|
|
26
|
+
else if ((option === '--config' && command === 'pack') || (option === '--sha256' && command === 'verify')) {
|
|
27
|
+
const value = values.shift();
|
|
28
|
+
if (!value || value.startsWith('--')) throw new TsPackError('arguments', 'Command option requires a value.');
|
|
29
|
+
if (option === '--config') configFile = value; else expectedManifestSha256 = value;
|
|
30
|
+
} else throw new TsPackError('arguments', 'Unknown command option.');
|
|
31
|
+
}
|
|
32
|
+
const pack = new TsPack();
|
|
33
|
+
const result = command === 'pack'
|
|
34
|
+
? await pack.pack(configFile ? (await readJson(plugins.path.resolve(configFile))).value as ITsPackConfig : undefined, { release, reuse })
|
|
35
|
+
: command === 'verify' && directory && !directory.startsWith('--')
|
|
36
|
+
? await pack.verify(directory, { requireRelease: release, expectedManifestSha256 })
|
|
37
|
+
: (() => { throw new TsPackError('arguments', 'Expected pack or verify with its required arguments.'); })();
|
|
38
|
+
console.log(JSON.stringify({ directory: result.directory, manifestSha256: result.manifestSha256,
|
|
39
|
+
publishable: result.manifest.publishable, reused: result.reused, bundles: result.manifest.artifacts.length }));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(error instanceof TsPackError ? `tspack: ${error.code}: ${error.message}` : 'tspack: operation failed.');
|
|
42
|
+
process.exitCode = 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
package/ts/validation.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import type { ITsPackConfig, ITsPackFileRecord, ITsPackManifest } from './interfaces.js';
|
|
3
|
+
|
|
4
|
+
export const limits = { file: 2 * 1024 ** 3, total: 8 * 1024 ** 3, files: 10_000, json: 16 * 1024 ** 2 };
|
|
5
|
+
export class TsPackError extends Error {
|
|
6
|
+
constructor(public readonly code: string, message: string) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'TsPackError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function requireValue(value: unknown, code: string, message: string): asserts value {
|
|
12
|
+
if (!value) throw new TsPackError(code, message);
|
|
13
|
+
}
|
|
14
|
+
export const sha256 = (value: string | Buffer) => plugins.crypto.createHash('sha256').update(value).digest('hex');
|
|
15
|
+
export const isDigest = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
16
|
+
export const isCommit = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(value);
|
|
17
|
+
export const isName = (value: unknown): value is string => typeof value === 'string' && /^[a-z0-9][a-z0-9._-]{0,99}$/.test(value);
|
|
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
|
+
export const isPath = (value: unknown): value is string => typeof value === 'string' && value.length <= 240 &&
|
|
20
|
+
/^[a-zA-Z0-9@_+./-]+$/.test(value) && !value.startsWith('/') && value.split('/').every((part) => part && part !== '.' && part !== '..');
|
|
21
|
+
export const jsonBytes = (value: unknown) => Buffer.from(JSON.stringify(value, null, 2) + '\n');
|
|
22
|
+
|
|
23
|
+
export function object(value: unknown, allowed?: readonly string[]): asserts value is Record<string, unknown> {
|
|
24
|
+
requireValue(value && typeof value === 'object' && !Array.isArray(value) && !plugins.util.types.isProxy(value) &&
|
|
25
|
+
[Object.prototype, null].includes(Object.getPrototypeOf(value)), 'invalid_config', 'Expected a plain record.');
|
|
26
|
+
requireValue(Reflect.ownKeys(value).every((key) => typeof key === 'string' &&
|
|
27
|
+
(!allowed || allowed.includes(key)) && Object.getOwnPropertyDescriptor(value, key)?.enumerable &&
|
|
28
|
+
'value' in Object.getOwnPropertyDescriptor(value, key)!), 'invalid_config', 'Unexpected record property.');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalizeConfig(value: unknown, packageName: string): Required<ITsPackConfig> {
|
|
32
|
+
object(value, ['schemaVersion', 'name', 'outputDirectory', 'bundles']);
|
|
33
|
+
const name = value.name ?? packageName.replace(/^@/, '').replace('/', '-');
|
|
34
|
+
const outputDirectory = value.outputDirectory ?? 'dist_pack';
|
|
35
|
+
requireValue(value.schemaVersion === 1 && isName(name) && isPath(outputDirectory) &&
|
|
36
|
+
Array.isArray(value.bundles) && value.bundles.length > 0 && value.bundles.length <= 128,
|
|
37
|
+
'invalid_config', 'Invalid tspack configuration.');
|
|
38
|
+
let count = 0;
|
|
39
|
+
const bundles = value.bundles.map((bundle: unknown) => {
|
|
40
|
+
object(bundle, ['id', 'files']);
|
|
41
|
+
requireValue(isName(bundle.id) && Array.isArray(bundle.files) && bundle.files.length > 0,
|
|
42
|
+
'invalid_config', 'A bundle requires an identifier and explicit files.');
|
|
43
|
+
const files = bundle.files.map((file: unknown) => {
|
|
44
|
+
object(file, ['source', 'path', 'executable', 'sha256']);
|
|
45
|
+
requireValue(isPath(file.source) && isPath(file.path) && file.path !== 'tspack.json' && !file.path.startsWith('tspack.json/') &&
|
|
46
|
+
(!Object.hasOwn(file, 'executable') || typeof file.executable === 'boolean') &&
|
|
47
|
+
(!Object.hasOwn(file, 'sha256') || isDigest(file.sha256)), 'invalid_config', 'Invalid bundle file.');
|
|
48
|
+
return { source: file.source, path: file.path, executable: file.executable === true,
|
|
49
|
+
...(file.sha256 ? { sha256: file.sha256 as string } : {}) };
|
|
50
|
+
}).sort((a, b) => a.path.localeCompare(b.path, 'en'));
|
|
51
|
+
count += files.length;
|
|
52
|
+
requireValue(new Set(files.map((file) => file.path)).size === files.length &&
|
|
53
|
+
files.every((file) => !files.some((other) => other.path.startsWith(file.path + '/'))),
|
|
54
|
+
'invalid_config', 'Bundle paths collide.');
|
|
55
|
+
return { id: bundle.id, files };
|
|
56
|
+
}).sort((a, b) => a.id.localeCompare(b.id, 'en'));
|
|
57
|
+
requireValue(count <= limits.files && new Set(bundles.map((bundle) => bundle.id)).size === bundles.length,
|
|
58
|
+
'invalid_config', 'Too many files or duplicate bundle identifiers.');
|
|
59
|
+
return { schemaVersion: 1, name, outputDirectory, bundles };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function validateFileRecord(value: unknown): asserts value is ITsPackFileRecord {
|
|
63
|
+
object(value, ['sha256', 'size', 'mode']);
|
|
64
|
+
requireValue(isDigest(value.sha256) && typeof value.size === 'number' && Number.isSafeInteger(value.size) &&
|
|
65
|
+
value.size >= 0 && value.size <= limits.file && (value.mode === 0o644 || value.mode === 0o755),
|
|
66
|
+
'invalid_manifest', 'Invalid file identity.');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function validateManifest(value: unknown): asserts value is ITsPackManifest {
|
|
70
|
+
object(value, ['format', 'packageName', 'name', 'version', 'sourceCommit', 'sourceDirty', 'publishable', 'configurationSha256', 'artifacts']);
|
|
71
|
+
requireValue(value.format === 'tspack.release.v1' && typeof value.packageName === 'string' && value.packageName.length <= 214 &&
|
|
72
|
+
isName(value.name) && isVersion(value.version) && isCommit(value.sourceCommit) &&
|
|
73
|
+
typeof value.sourceDirty === 'boolean' && typeof value.publishable === 'boolean' &&
|
|
74
|
+
(!value.publishable || !value.sourceDirty) && isDigest(value.configurationSha256) &&
|
|
75
|
+
Array.isArray(value.artifacts) && value.artifacts.length > 0 && value.artifacts.length <= 128,
|
|
76
|
+
'invalid_manifest', 'Invalid artifact-set manifest.');
|
|
77
|
+
const ids = new Set();
|
|
78
|
+
const names = new Set();
|
|
79
|
+
let total = 0;
|
|
80
|
+
let count = 0;
|
|
81
|
+
for (const artifact of value.artifacts) {
|
|
82
|
+
object(artifact, ['id', 'name', 'sha256', 'size', 'files']);
|
|
83
|
+
requireValue(isName(artifact.id) && !ids.has(artifact.id) &&
|
|
84
|
+
isPath(artifact.name) && artifact.name === `${value.name}-${value.version}-${artifact.id}.tar.gz` && !names.has(artifact.name) &&
|
|
85
|
+
isDigest(artifact.sha256) && typeof artifact.size === 'number' && Number.isSafeInteger(artifact.size) &&
|
|
86
|
+
artifact.size > 0 && artifact.size <= limits.total, 'invalid_manifest', 'Invalid archive identity.');
|
|
87
|
+
ids.add(artifact.id); names.add(artifact.name);
|
|
88
|
+
object(artifact.files);
|
|
89
|
+
requireValue(Object.hasOwn(artifact.files, 'tspack.json') && Object.keys(artifact.files).length > 1,
|
|
90
|
+
'invalid_manifest', 'Bundle metadata or selected files are missing.');
|
|
91
|
+
for (const [name, record] of Object.entries(artifact.files)) {
|
|
92
|
+
requireValue(isPath(name), 'invalid_manifest', 'Invalid archive member path.');
|
|
93
|
+
validateFileRecord(record);
|
|
94
|
+
total += record.size; count++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
requireValue(total <= limits.total && count <= limits.files + 128, 'invalid_manifest', 'Artifact set exceeds its limits.');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function noSymlinks(root: string, relative: string, allowMissing = false): Promise<string> {
|
|
101
|
+
requireValue(isPath(relative), 'unsafe_input', 'A canonical project-relative path is required.');
|
|
102
|
+
let current = root;
|
|
103
|
+
for (const part of relative.split('/')) {
|
|
104
|
+
current = plugins.path.join(current, part);
|
|
105
|
+
try {
|
|
106
|
+
requireValue(!(await plugins.fs.lstat(current)).isSymbolicLink(), 'unsafe_input', 'Symlink paths are not package inputs or outputs.');
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (allowMissing && (error as NodeJS.ErrnoException).code === 'ENOENT') break;
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return plugins.path.join(root, relative);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function readJson(file: string): Promise<{ value: unknown; bytes: Buffer }> {
|
|
116
|
+
const stat = await plugins.fs.lstat(file);
|
|
117
|
+
requireValue(stat.isFile() && stat.size <= limits.json, 'invalid_manifest', 'Expected bounded regular JSON metadata.');
|
|
118
|
+
const bytes = await plugins.fs.readFile(file);
|
|
119
|
+
requireValue(bytes.length <= limits.json, 'invalid_manifest', 'JSON metadata exceeds its limit.');
|
|
120
|
+
try { return { value: JSON.parse(bytes.toString('utf8')), bytes }; }
|
|
121
|
+
catch { throw new TsPackError('invalid_manifest', 'Invalid JSON metadata.'); }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function digestFile(file: string): Promise<{ sha256: string; size: number }> {
|
|
125
|
+
const stat = await plugins.fs.lstat(file);
|
|
126
|
+
requireValue(stat.isFile() && stat.size <= limits.total, 'integrity', 'Expected a bounded regular artifact file.');
|
|
127
|
+
const hash = plugins.crypto.createHash('sha256');
|
|
128
|
+
let size = 0;
|
|
129
|
+
for await (const chunk of plugins.nodeFs.createReadStream(file)) {
|
|
130
|
+
size += chunk.length;
|
|
131
|
+
requireValue(size <= stat.size, 'integrity', 'Artifact changed while reading.');
|
|
132
|
+
hash.update(chunk);
|
|
133
|
+
}
|
|
134
|
+
requireValue(size === stat.size, 'integrity', 'Artifact size changed while reading.');
|
|
135
|
+
return { sha256: hash.digest('hex'), size };
|
|
136
|
+
}
|