android-publisher-cli 0.0.0-stage → 0.2.0-dev

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.
@@ -0,0 +1,273 @@
1
+ import path from 'node:path';
2
+ import { inspectAndFreeze, verifyFrozen } from './artifact.js';
3
+ import { channelAdapter } from './channels/index.js';
4
+ import { ChannelError } from './channels/http.js';
5
+ import { canonical, digest, isChannel, validAbsolute, validId, validateBatchInput, validateProject } from './model.js';
6
+ import { dataRoot, freezeFile, hashFile, readJSON, regularFile, writePrivate } from './private-file.js';
7
+ export function projectPath(root, id) {
8
+ if (!validId(id))
9
+ throw new Error('invalid project ID');
10
+ return path.join(root, 'projects', `${id}.json`);
11
+ }
12
+ export function saveProject(project, root = dataRoot(), replace = false) {
13
+ validateProject(project);
14
+ writePrivate(projectPath(root, project.project_id), JSON.stringify(project, null, 2) + '\n', replace, true);
15
+ }
16
+ export function loadProject(id, root = dataRoot()) {
17
+ return validateProject(readJSON(projectPath(root, id), 64 * 1024, true));
18
+ }
19
+ export function prepare(input, tools, store) {
20
+ validateBatchInput(input);
21
+ const project = loadProject(input.project_id, store.root);
22
+ const selected = Object.keys(input.apks);
23
+ if (selected.length !== project.channels.length || project.channels.some(ch => !selected.includes(ch.channel)))
24
+ throw new Error('one APK is required for every configured channel');
25
+ const notes = new TextDecoder('utf-8', { fatal: true }).decode(regularFile(input.release_notes_file, 64 * 1024));
26
+ if ([...notes.trim()].length < 5 || [...notes].length > 500 || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(notes))
27
+ throw new Error('release notes must have 5–500 characters');
28
+ const targets = [];
29
+ let versionName = '';
30
+ let versionCode = '';
31
+ for (const config of project.channels) {
32
+ const apkPath = input.apks[config.channel];
33
+ if (!apkPath)
34
+ throw new Error(`${config.channel}: APK path missing`);
35
+ const artifact = inspectAndFreeze(tools, apkPath, store.root);
36
+ if (artifact.package_name !== project.package_name || artifact.signer_sha256 !== project.signer_sha256)
37
+ throw new Error(`${config.channel}: APK package or signer differs from the approved project`);
38
+ if (!versionName) {
39
+ versionName = artifact.version_name;
40
+ versionCode = artifact.version_code;
41
+ }
42
+ if (versionName !== artifact.version_name || versionCode !== artifact.version_code)
43
+ throw new Error('all APKs must have the same version');
44
+ const target = { channel: config.channel, account: config.account, app_id: config.app_id,
45
+ artifact, ...(config.client_id ? { client_id: config.client_id } : {}),
46
+ ...(config.language_id ? { language_id: config.language_id } : {}),
47
+ ...(config.privacy_url ? { privacy_url: config.privacy_url } : {}) };
48
+ if (config.channel === 'xiaomi') {
49
+ const iconSource = config.icon_path;
50
+ const iconHash = hashFile(iconSource);
51
+ const frozenIcon = path.join(store.root, 'artifacts', iconHash.sha256, `icon${path.extname(iconSource) || '.png'}`);
52
+ freezeFile(iconSource, frozenIcon, iconHash.sha256);
53
+ target.icon_path = frozenIcon;
54
+ target.icon_sha256 = iconHash.sha256;
55
+ target.icon_size = iconHash.size;
56
+ }
57
+ targets.push(target);
58
+ }
59
+ const body = { schema_version: 2, batch_id: input.batch_id, project_id: project.project_id,
60
+ package_name: project.package_name, version_name: versionName, version_code: versionCode,
61
+ signer_sha256: project.signer_sha256, release_notes: notes, release_policy: 'after_approval', targets };
62
+ const batch = { ...body, digest: digest(body), created_at: new Date().toISOString() };
63
+ store.saveBatch(batch);
64
+ return batch;
65
+ }
66
+ function selectedTargets(batch, channels) {
67
+ if (!channels?.length)
68
+ return batch.targets;
69
+ if (new Set(channels).size !== channels.length)
70
+ throw new Error('duplicate channel selection');
71
+ const rows = channels.map(channel => batch.targets.find(target => target.channel === channel));
72
+ if (rows.some(row => !row))
73
+ throw new Error('selected channel is outside the immutable batch');
74
+ return rows;
75
+ }
76
+ export async function preflight(store, batchId, credentials, channels, fetcher = fetch) {
77
+ const batch = store.loadBatch(batchId);
78
+ const errors = {};
79
+ for (const target of selectedTargets(batch, channels)) {
80
+ try {
81
+ const secrets = await credentials.forChannel(target.channel);
82
+ const adapter = channelAdapter(batch, target, secrets, store, fetcher);
83
+ const result = await adapter.preflight();
84
+ store.savePreflight(batchId, result);
85
+ }
86
+ catch (error) {
87
+ errors[target.channel] = publicError(error);
88
+ }
89
+ }
90
+ return { ...store.summary(batchId), effect: 'read_only', errors };
91
+ }
92
+ function approvalBody(store, batch, channels, requireReady = false) {
93
+ const selected = selectedTargets(batch, channels);
94
+ const preflightDigests = {};
95
+ const hashes = {};
96
+ for (const target of selected) {
97
+ const row = store.target(batch.batch_id, target.channel);
98
+ if (!row.preflight_digest || requireReady && row.phase !== 'preflighted')
99
+ throw new Error(`${target.channel}: a clean preflight is required`);
100
+ if (requireReady && store.operations(batch.batch_id, target.channel).some(op => ['intent', 'unknown'].includes(op.effect)))
101
+ throw new Error(`${target.channel}: unresolved write blocks review`);
102
+ preflightDigests[target.channel] = row.preflight_digest;
103
+ hashes[target.channel] = target.artifact.sha256;
104
+ }
105
+ return { schema_version: 2, batch_id: batch.batch_id, batch_digest: batch.digest,
106
+ selected_channels: selected.map(target => target.channel),
107
+ target_accounts: selected.map(target => ({ channel: target.channel, account: target.account,
108
+ app_id: target.app_id, ...(target.client_id ? { client_id: target.client_id } : {}) })),
109
+ preflight_digests: preflightDigests,
110
+ package_name: batch.package_name, version_name: batch.version_name, version_code: batch.version_code,
111
+ artifact_sha256: hashes, signer_sha256: batch.signer_sha256, release_notes: batch.release_notes,
112
+ release_policy: batch.release_policy };
113
+ }
114
+ export function review(store, batchId, file, channels) {
115
+ if (!validAbsolute(file))
116
+ throw new Error('approval file path must be absolute');
117
+ const body = approvalBody(store, store.loadBatch(batchId), channels, true);
118
+ const approval = { ...body, approval_plan: digest(body) };
119
+ writePrivate(file, JSON.stringify(approval, null, 2) + '\n');
120
+ return approval;
121
+ }
122
+ function validatedApproval(store, batch, file, approvedPlan) {
123
+ const raw = readJSON(file, 8 * 1024 * 1024, true);
124
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
125
+ throw new Error('approval file is invalid');
126
+ const approval = raw;
127
+ if (approval.schema_version !== 2 || approval.batch_id !== batch.batch_id ||
128
+ approval.approval_plan !== approvedPlan || !Array.isArray(approval.selected_channels) ||
129
+ approval.selected_channels.some(ch => !isChannel(ch)))
130
+ throw new Error('approval does not match the requested plan');
131
+ const body = approvalBody(store, batch, approval.selected_channels);
132
+ const expected = { ...body, approval_plan: digest(body) };
133
+ if (canonical(approval) !== canonical(expected))
134
+ throw new Error('approval file or reviewed inputs changed');
135
+ store.verifyApproval(approval);
136
+ return approval;
137
+ }
138
+ function samePreflightMeaning(a, b) {
139
+ const { inspected_at: _aTime, digest: _aDigest, ...left } = a;
140
+ const { inspected_at: _bTime, digest: _bDigest, ...right } = b;
141
+ return canonical(left) === canonical(right);
142
+ }
143
+ export function publicError(error) {
144
+ if (error instanceof ChannelError)
145
+ return error.message;
146
+ if (error instanceof Error)
147
+ return error.message.slice(0, 256);
148
+ return 'operation failed';
149
+ }
150
+ async function runTarget(store, batch, target, adapter, approval, resume) {
151
+ const row = store.target(batch.batch_id, target.channel);
152
+ if (row.phase === 'submitted')
153
+ return;
154
+ const existing = store.operations(batch.batch_id, target.channel);
155
+ if (!resume && existing.length)
156
+ throw new Error('target has an earlier operation; use resume after checking its status');
157
+ if (!existing.length) {
158
+ const observed = await adapter.preflight();
159
+ const saved = JSON.parse(row.preflight);
160
+ if (!samePreflightMeaning(observed, saved))
161
+ throw new Error('remote baseline changed after review; run preflight and review again');
162
+ }
163
+ for (const step of adapter.steps) {
164
+ const previous = store.latestOperation(batch.batch_id, target.channel, step);
165
+ if (previous?.effect === 'confirmed_applied')
166
+ continue;
167
+ if (previous && ['intent', 'unknown', 'confirmed_rejected'].includes(previous.effect))
168
+ throw new Error(`${step}: result is unresolved; run reconcile`);
169
+ if (adapter.waitBeforeStep)
170
+ await adapter.waitBeforeStep(step);
171
+ const checkpoints = Object.fromEntries(store.operations(batch.batch_id, target.channel)
172
+ .filter(op => op.effect === 'confirmed_applied').map(op => [op.step, op.remote_ref]));
173
+ const requestDigest = digest({ batch: batch.digest, approval: approval.approval_plan,
174
+ preflight: row.preflight_digest, channel: target.channel, step, checkpoints });
175
+ const operation = store.beginOperation(batch.batch_id, target.channel, step, requestDigest);
176
+ try {
177
+ const receipt = await adapter.execute(step, operation.id);
178
+ if (!receipt.ref || receipt.ref.length > 2048 || /[\r\n\u0000]/.test(receipt.ref))
179
+ throw new Error('vendor receipt is missing or invalid');
180
+ store.finishOperation(operation, 'confirmed_applied', receipt.ref, receipt.material);
181
+ }
182
+ catch (error) {
183
+ store.finishOperation(operation, error instanceof ChannelError && error.effect === 'not_sent' ? 'confirmed_not_applied' : 'unknown');
184
+ throw error;
185
+ }
186
+ }
187
+ const last = store.latestOperation(batch.batch_id, target.channel, adapter.steps.at(-1));
188
+ if (!last || last.effect !== 'confirmed_applied')
189
+ throw new Error('submit receipt missing');
190
+ store.setSubmitted(batch.batch_id, target.channel, last.remote_ref);
191
+ }
192
+ export async function submit(store, batchId, approvalFile, approvedPlan, credentials, tools, resume = false, fetcher = fetch) {
193
+ const batch = store.loadBatch(batchId);
194
+ const approval = validatedApproval(store, batch, approvalFile, approvedPlan);
195
+ // Verify every selected artifact before the first channel can write.
196
+ for (const target of selectedTargets(batch, approval.selected_channels)) {
197
+ verifyFrozen(tools, target.artifact);
198
+ if (target.icon_path && (hashFile(target.icon_path).sha256 !== target.icon_sha256))
199
+ throw new Error('frozen icon changed');
200
+ const row = store.target(batchId, target.channel);
201
+ store.assertScopeAvailable(row.scope, batchId, target.channel);
202
+ }
203
+ const errors = {};
204
+ for (const target of selectedTargets(batch, approval.selected_channels)) {
205
+ try {
206
+ const secrets = await credentials.forChannel(target.channel);
207
+ const adapter = channelAdapter(batch, target, secrets, store, fetcher);
208
+ await runTarget(store, batch, target, adapter, approval, resume);
209
+ }
210
+ catch (error) {
211
+ errors[target.channel] = publicError(error);
212
+ }
213
+ }
214
+ return { ...store.summary(batchId), effect: Object.keys(errors).length ? 'partial_or_unknown' : 'submitted', errors };
215
+ }
216
+ export async function refresh(store, batchId, credentials, channels, fetcher = fetch) {
217
+ const batch = store.loadBatch(batchId);
218
+ const errors = {};
219
+ for (const target of selectedTargets(batch, channels)) {
220
+ try {
221
+ const secrets = await credentials.forChannel(target.channel);
222
+ const adapter = channelAdapter(batch, target, secrets, store, fetcher);
223
+ const observed = await adapter.observe(store.target(batchId, target.channel).remote_ref);
224
+ store.saveObservation(batchId, target.channel, observed);
225
+ }
226
+ catch (error) {
227
+ errors[target.channel] = publicError(error);
228
+ store.saveQueryFailure(batchId, target.channel, errors[target.channel]);
229
+ }
230
+ }
231
+ return { ...store.summary(batchId), effect: 'read_only', errors };
232
+ }
233
+ export async function reconcile(store, batchId, credentials, channels, fetcher = fetch) {
234
+ const batch = store.loadBatch(batchId);
235
+ const errors = {};
236
+ for (const target of selectedTargets(batch, channels)) {
237
+ const pending = store.operations(batchId, target.channel).filter(op => ['intent', 'unknown'].includes(op.effect));
238
+ if (!pending.length)
239
+ continue;
240
+ try {
241
+ const secrets = await credentials.forChannel(target.channel);
242
+ const adapter = channelAdapter(batch, target, secrets, store, fetcher);
243
+ for (const op of pending) {
244
+ const result = await adapter.reconcile(op);
245
+ if (result.effect === 'unknown') {
246
+ try {
247
+ store.saveObservation(batchId, target.channel, await adapter.observe(store.target(batchId, target.channel).remote_ref));
248
+ }
249
+ catch { /* No read-only source can prove this write. Keep the durable block. */ }
250
+ errors[target.channel] = 'read-only evidence cannot resolve this write';
251
+ continue;
252
+ }
253
+ store.resolveOperation(op, result.effect, result.ref ?? '');
254
+ }
255
+ }
256
+ catch (error) {
257
+ errors[target.channel] = publicError(error);
258
+ }
259
+ }
260
+ return { ...store.summary(batchId), effect: 'read_only', errors };
261
+ }
262
+ export function report(store, batchId) {
263
+ const batch = store.loadBatch(batchId);
264
+ const summary = store.summary(batchId);
265
+ const lines = [`# ${batch.batch_id} 发布报告`, '', `包名:${batch.package_name}。`,
266
+ `版本:${batch.version_name}(${batch.version_code})。`, `批次摘要:\`${batch.digest}\`。`, '',
267
+ '| 渠道 | 执行 | 审核 | 上架 | 查询 |', '| --- | --- | --- | --- | --- |'];
268
+ const targets = summary.targets;
269
+ for (const row of targets)
270
+ lines.push(`| ${row.channel} | ${row.execution_phase} | ${row.review_state} | ${row.availability_state} | ${row.query_status} |`);
271
+ lines.push('', '本报告只记录本地回执和已关联的观测。审核与上架状态以各字段为准。', '');
272
+ return lines.join('\n');
273
+ }
package/build/model.js ADDED
@@ -0,0 +1,160 @@
1
+ import { createHash } from 'node:crypto';
2
+ import path from 'node:path';
3
+ export const CHANNELS = ['huawei', 'honor', 'xiaomi', 'oppo', 'vivo', 'tencent'];
4
+ export const validId = (s) => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(s);
5
+ export const validHash = (s) => typeof s === 'string' && /^[a-f0-9]{64}$/.test(s);
6
+ export const validPackage = (s) => typeof s === 'string' && /^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/.test(s) && s.length <= 255;
7
+ export const validVersion = (s) => typeof s === 'string' && /^[1-9][0-9]{0,18}$/.test(s) && BigInt(s) <= 9223372036854775807n;
8
+ export const validText = (s, max) => typeof s === 'string' && Buffer.byteLength(s) <= max && !/[\u0000-\u001f\u007f\u2028\u2029]/u.test(s);
9
+ export const validAbsolute = (s) => typeof s === 'string' && s.length <= 4096 && path.isAbsolute(s) && path.normalize(s) === s && validText(s, 4096);
10
+ export const isChannel = (s) => typeof s === 'string' && CHANNELS.includes(s);
11
+ export function assertObject(value, keys, optional = []) {
12
+ if (!value || typeof value !== 'object' || Array.isArray(value))
13
+ throw new Error('expected an object');
14
+ const got = Object.keys(value);
15
+ if (got.some(k => !keys.includes(k) && !optional.includes(k)) || keys.some(k => !got.includes(k)))
16
+ throw new Error('unexpected or missing field');
17
+ }
18
+ // JSON.parse silently accepts duplicate identity keys. Check the token stream first.
19
+ export function parseStrictJSON(source) {
20
+ if (Buffer.byteLength(source) > 8 * 1024 * 1024)
21
+ throw new Error('JSON input is too large');
22
+ let i = 0;
23
+ const ws = () => { while (i < source.length && /[ \t\r\n]/.test(source[i]))
24
+ i++; };
25
+ const quoted = () => {
26
+ const start = i++;
27
+ while (i < source.length) {
28
+ const ch = source[i++];
29
+ if (ch === '\\') {
30
+ i++;
31
+ continue;
32
+ }
33
+ if (ch === '"')
34
+ return JSON.parse(source.slice(start, i));
35
+ }
36
+ throw new Error('unterminated JSON string');
37
+ };
38
+ const scan = (depth) => {
39
+ if (depth > 32)
40
+ throw new Error('JSON nesting is too deep');
41
+ ws();
42
+ if (source[i] === '{') {
43
+ i++;
44
+ ws();
45
+ const seen = new Set();
46
+ const result = {};
47
+ if (source[i] === '}') {
48
+ i++;
49
+ return result;
50
+ }
51
+ for (;;) {
52
+ ws();
53
+ if (source[i] !== '"')
54
+ throw new Error('invalid JSON object');
55
+ const key = quoted();
56
+ if (seen.has(key) || key === '__proto__' || key === 'constructor')
57
+ throw new Error(`duplicate or unsafe JSON field: ${key}`);
58
+ seen.add(key);
59
+ ws();
60
+ if (source[i++] !== ':')
61
+ throw new Error('invalid JSON object');
62
+ result[key] = scan(depth + 1);
63
+ ws();
64
+ const end = source[i++];
65
+ if (end === '}')
66
+ return result;
67
+ if (end !== ',')
68
+ throw new Error('invalid JSON object');
69
+ }
70
+ }
71
+ if (source[i] === '[') {
72
+ i++;
73
+ ws();
74
+ const result = [];
75
+ if (source[i] === ']') {
76
+ i++;
77
+ return result;
78
+ }
79
+ for (;;) {
80
+ result.push(scan(depth + 1));
81
+ ws();
82
+ const end = source[i++];
83
+ if (end === ']')
84
+ return result;
85
+ if (end !== ',')
86
+ throw new Error('invalid JSON array');
87
+ }
88
+ }
89
+ if (source[i] === '"')
90
+ return quoted();
91
+ const start = i;
92
+ while (i < source.length && !/[ \t\r\n,}\]]/.test(source[i]))
93
+ i++;
94
+ if (i === start)
95
+ throw new Error('invalid JSON value');
96
+ const token = source.slice(start, i);
97
+ const value = JSON.parse(token);
98
+ if (typeof value === 'number' && /^-?(?:0|[1-9][0-9]*)$/.test(token) && !Number.isSafeInteger(value))
99
+ return BigInt(token);
100
+ return value;
101
+ };
102
+ const value = scan(0);
103
+ ws();
104
+ if (i !== source.length)
105
+ throw new Error('trailing JSON content');
106
+ return value;
107
+ }
108
+ export function canonical(value) {
109
+ if (typeof value === 'bigint')
110
+ return value.toString();
111
+ if (Array.isArray(value))
112
+ return `[${value.map(canonical).join(',')}]`;
113
+ if (value && typeof value === 'object') {
114
+ const fields = Object.entries(value).filter(([, v]) => v !== undefined).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
115
+ return `{${fields.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(',')}}`;
116
+ }
117
+ return JSON.stringify(value);
118
+ }
119
+ export const digest = (value) => createHash('sha256').update(typeof value === 'string' ? value : canonical(value)).digest('hex');
120
+ export function validateProject(value) {
121
+ assertObject(value, ['schema_version', 'project_id', 'package_name', 'signer_sha256', 'channels']);
122
+ if (value.schema_version !== 2 || !validId(value.project_id) || !validPackage(value.package_name) || !validHash(value.signer_sha256) || !Array.isArray(value.channels) || value.channels.length < 1 || value.channels.length > CHANNELS.length)
123
+ throw new Error('invalid project identity');
124
+ const seen = new Set();
125
+ for (const row of value.channels) {
126
+ assertObject(row, ['channel', 'account', 'app_id'], ['client_id', 'language_id', 'icon_path', 'privacy_url']);
127
+ if (!isChannel(row.channel) || seen.has(row.channel) || !validId(row.account) || !validId(row.app_id))
128
+ throw new Error('invalid or duplicate channel');
129
+ seen.add(row.channel);
130
+ if (row.client_id !== undefined && !validId(row.client_id))
131
+ throw new Error('invalid client_id');
132
+ if (row.language_id !== undefined && (!validText(row.language_id, 32) || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(row.language_id)))
133
+ throw new Error('invalid language_id');
134
+ if (row.icon_path !== undefined && !validAbsolute(row.icon_path))
135
+ throw new Error('invalid icon_path');
136
+ if (row.privacy_url !== undefined) {
137
+ if (typeof row.privacy_url !== 'string')
138
+ throw new Error('invalid privacy_url');
139
+ const url = new URL(row.privacy_url);
140
+ if (url.protocol !== 'https:' || url.username || url.password || url.hash || url.search || !url.hostname)
141
+ throw new Error('invalid privacy_url');
142
+ }
143
+ if (row.channel === 'xiaomi' && (!row.icon_path || !row.privacy_url || !row.client_id))
144
+ throw new Error('Xiaomi needs client_id, icon_path and privacy_url');
145
+ if (['honor', 'vivo', 'tencent'].includes(row.channel) && !row.client_id)
146
+ throw new Error(`${row.channel} needs client_id`);
147
+ }
148
+ return value;
149
+ }
150
+ export function validateBatchInput(value) {
151
+ assertObject(value, ['schema_version', 'batch_id', 'project_id', 'release_notes_file', 'apks']);
152
+ if (value.schema_version !== 2 || !validId(value.batch_id) || !validId(value.project_id) || !validAbsolute(value.release_notes_file))
153
+ throw new Error('invalid batch input');
154
+ if (!value.apks || typeof value.apks !== 'object' || Array.isArray(value.apks) || !Object.keys(value.apks).length)
155
+ throw new Error('missing APK paths');
156
+ for (const [channel, apk] of Object.entries(value.apks))
157
+ if (!isChannel(channel) || !validAbsolute(apk))
158
+ throw new Error('invalid APK channel or path');
159
+ return value;
160
+ }
@@ -0,0 +1,193 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { parseStrictJSON } from './model.js';
6
+ export const dataRoot = () => path.join(os.homedir(), 'Library', 'Application Support', 'AndroidPublisherCLI', 'v2');
7
+ function noSymlinkAncestors(file) {
8
+ let current = path.dirname(file);
9
+ while (current !== path.dirname(current)) {
10
+ const st = fs.lstatSync(current);
11
+ if (st.isSymbolicLink() || !st.isDirectory())
12
+ throw new Error('file path contains a symbolic link');
13
+ current = path.dirname(current);
14
+ }
15
+ }
16
+ export function privateDir(dir) {
17
+ if (!path.isAbsolute(dir) || path.normalize(dir) !== dir)
18
+ throw new Error('directory must be an absolute normalized path');
19
+ const parts = dir.split(path.sep).filter(Boolean);
20
+ let current = path.sep;
21
+ for (const part of parts) {
22
+ current = path.join(current, part);
23
+ if (!fs.existsSync(current))
24
+ fs.mkdirSync(current, { mode: 0o700 });
25
+ const st = fs.lstatSync(current);
26
+ if (!st.isDirectory() || st.isSymbolicLink())
27
+ throw new Error('directory contains a symbolic link');
28
+ }
29
+ fs.chmodSync(dir, 0o700);
30
+ }
31
+ export function regularFile(file, maxBytes, privateOnly = false) {
32
+ if (!path.isAbsolute(file) || path.normalize(file) !== file)
33
+ throw new Error('file path must be absolute and normalized');
34
+ noSymlinkAncestors(file);
35
+ const st = fs.lstatSync(file);
36
+ if (!st.isFile() || st.isSymbolicLink() || st.size > maxBytes || st.size < 0)
37
+ throw new Error('file is not an allowed regular file');
38
+ if (privateOnly && ((st.mode & 0o077) !== 0 || st.uid !== process.getuid?.()))
39
+ throw new Error('file must belong to its owner and have mode 0600');
40
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
41
+ try {
42
+ const opened = fs.fstatSync(fd);
43
+ if (!opened.isFile() || opened.ino !== st.ino || opened.dev !== st.dev || opened.size > maxBytes)
44
+ throw new Error('file changed while opening');
45
+ return fs.readFileSync(fd);
46
+ }
47
+ finally {
48
+ fs.closeSync(fd);
49
+ }
50
+ }
51
+ export function readJSON(file, maxBytes = 1024 * 1024, privateOnly = false) {
52
+ return parseStrictJSON(regularFile(file, maxBytes, privateOnly).toString('utf8'));
53
+ }
54
+ export function writePrivate(file, content, replace = false, createParents = false) {
55
+ if (!path.isAbsolute(file) || path.normalize(file) !== file)
56
+ throw new Error('output file path must be absolute');
57
+ if (createParents)
58
+ privateDir(path.dirname(file));
59
+ else
60
+ noSymlinkAncestors(file);
61
+ if (!replace && fs.existsSync(file))
62
+ throw new Error('file exists; choose a new path');
63
+ const temp = path.join(path.dirname(file), `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
64
+ const fd = fs.openSync(temp, 'wx', 0o600);
65
+ try {
66
+ fs.writeFileSync(fd, content);
67
+ fs.fsyncSync(fd);
68
+ }
69
+ finally {
70
+ fs.closeSync(fd);
71
+ }
72
+ try {
73
+ if (replace) {
74
+ if (fs.existsSync(file) && !fs.lstatSync(file).isFile())
75
+ throw new Error('destination is not a regular file');
76
+ fs.renameSync(temp, file);
77
+ }
78
+ else {
79
+ fs.linkSync(temp, file);
80
+ fs.unlinkSync(temp);
81
+ }
82
+ const dir = fs.openSync(path.dirname(file), 'r');
83
+ try {
84
+ fs.fsyncSync(dir);
85
+ }
86
+ finally {
87
+ fs.closeSync(dir);
88
+ }
89
+ }
90
+ finally {
91
+ if (fs.existsSync(temp))
92
+ fs.unlinkSync(temp);
93
+ }
94
+ }
95
+ export function hashFile(file, maxBytes = 2 * 1024 * 1024 * 1024) {
96
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
97
+ try {
98
+ const st = fs.fstatSync(fd);
99
+ if (!st.isFile() || st.size <= 0 || st.size > maxBytes)
100
+ throw new Error('file size or type is invalid');
101
+ const h = createHash('sha256');
102
+ const chunk = Buffer.allocUnsafe(1024 * 1024);
103
+ let pos = 0;
104
+ while (pos < st.size) {
105
+ const count = fs.readSync(fd, chunk, 0, Math.min(chunk.length, st.size - pos), pos);
106
+ if (count <= 0)
107
+ throw new Error('file changed while hashing');
108
+ h.update(chunk.subarray(0, count));
109
+ pos += count;
110
+ }
111
+ if (fs.fstatSync(fd).size !== st.size)
112
+ throw new Error('file changed while hashing');
113
+ return { sha256: h.digest('hex'), size: st.size };
114
+ }
115
+ finally {
116
+ fs.closeSync(fd);
117
+ }
118
+ }
119
+ export function md5File(file, expectedSHA256) {
120
+ if (hashFile(file).sha256 !== expectedSHA256)
121
+ throw new Error('frozen file changed');
122
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
123
+ try {
124
+ const st = fs.fstatSync(fd);
125
+ const h = createHash('md5');
126
+ const chunk = Buffer.allocUnsafe(1024 * 1024);
127
+ let pos = 0;
128
+ while (pos < st.size) {
129
+ const count = fs.readSync(fd, chunk, 0, Math.min(chunk.length, st.size - pos), pos);
130
+ if (count <= 0)
131
+ throw new Error('frozen file changed');
132
+ h.update(chunk.subarray(0, count));
133
+ pos += count;
134
+ }
135
+ return h.digest('hex');
136
+ }
137
+ finally {
138
+ fs.closeSync(fd);
139
+ }
140
+ }
141
+ export function freezeFile(source, destination, expectedHash) {
142
+ privateDir(path.dirname(destination));
143
+ if (fs.existsSync(destination)) {
144
+ if (hashFile(destination).sha256 !== expectedHash)
145
+ throw new Error('frozen file differs from its saved hash');
146
+ return;
147
+ }
148
+ const temp = path.join(path.dirname(destination), `.freeze-${process.pid}-${Date.now()}`);
149
+ const src = fs.openSync(source, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
150
+ const dst = fs.openSync(temp, 'wx', 0o600);
151
+ try {
152
+ const st = fs.fstatSync(src);
153
+ if (!st.isFile())
154
+ throw new Error('source is not a regular file');
155
+ const h = createHash('sha256');
156
+ const chunk = Buffer.allocUnsafe(1024 * 1024);
157
+ let pos = 0;
158
+ while (pos < st.size) {
159
+ const count = fs.readSync(src, chunk, 0, Math.min(chunk.length, st.size - pos), pos);
160
+ if (count <= 0)
161
+ throw new Error('source changed while freezing');
162
+ h.update(chunk.subarray(0, count));
163
+ let written = 0;
164
+ while (written < count)
165
+ written += fs.writeSync(dst, chunk, written, count - written);
166
+ pos += count;
167
+ }
168
+ fs.fsyncSync(dst);
169
+ if (h.digest('hex') !== expectedHash || fs.fstatSync(src).size !== st.size)
170
+ throw new Error('source changed while freezing');
171
+ }
172
+ catch (e) {
173
+ fs.closeSync(src);
174
+ fs.closeSync(dst);
175
+ fs.unlinkSync(temp);
176
+ throw e;
177
+ }
178
+ fs.closeSync(src);
179
+ fs.closeSync(dst);
180
+ try {
181
+ fs.linkSync(temp, destination);
182
+ const dir = fs.openSync(path.dirname(destination), 'r');
183
+ try {
184
+ fs.fsyncSync(dir);
185
+ }
186
+ finally {
187
+ fs.closeSync(dir);
188
+ }
189
+ }
190
+ finally {
191
+ fs.unlinkSync(temp);
192
+ }
193
+ }