@aipt/idp-deploy 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +471 -0
  2. package/bin/idpctl.mjs +8 -0
  3. package/compose/edge.yaml +61 -0
  4. package/compose/flow.yaml +34 -0
  5. package/compose/portal.yaml +38 -0
  6. package/compose/postgresql.yaml +62 -0
  7. package/compose/registry.yaml +35 -0
  8. package/compose/smartgo.yaml +271 -0
  9. package/compose/tech.yaml +64 -0
  10. package/contracts/active-profile.schema.json +19 -0
  11. package/contracts/asset-lifecycle.schema.json +49 -0
  12. package/contracts/backup-generation.schema.json +60 -0
  13. package/contracts/component-runtime.schema.json +32 -0
  14. package/contracts/config-release.schema.json +33 -0
  15. package/contracts/deployment-evidence.schema.json +43 -0
  16. package/contracts/deployment-plan.schema.json +67 -0
  17. package/contracts/release-candidate.schema.json +33 -0
  18. package/contracts/release-defaults.schema.json +56 -0
  19. package/contracts/restore-candidate.schema.json +63 -0
  20. package/contracts/smartgo-component-config.schema.json +79 -0
  21. package/contracts/tech-backup-boundary.schema.json +61 -0
  22. package/contracts/tech-source-credentials.schema.json +17 -0
  23. package/deploy.sh +5 -0
  24. package/docs/restore-runbook.md +56 -0
  25. package/governance/asset-lifecycle.v1.json +79 -0
  26. package/package.json +42 -0
  27. package/release/defaults.v1.json +64 -0
  28. package/src/acceptance.mjs +127 -0
  29. package/src/bindings.mjs +518 -0
  30. package/src/cli.mjs +360 -0
  31. package/src/compose.mjs +19 -0
  32. package/src/config.mjs +903 -0
  33. package/src/delivery.mjs +123 -0
  34. package/src/errors.mjs +12 -0
  35. package/src/foundation-contracts.mjs +128 -0
  36. package/src/foundation.mjs +107 -0
  37. package/src/gitops.mjs +285 -0
  38. package/src/hash.mjs +47 -0
  39. package/src/image-lock.mjs +160 -0
  40. package/src/images.mjs +215 -0
  41. package/src/lifecycle.mjs +58 -0
  42. package/src/local-source.mjs +354 -0
  43. package/src/oci-mirror.mjs +10 -0
  44. package/src/operations.mjs +1484 -0
  45. package/src/process.mjs +46 -0
  46. package/src/profiles.mjs +41 -0
  47. package/src/release.mjs +1760 -0
  48. package/src/render.mjs +330 -0
  49. package/src/security.mjs +156 -0
  50. package/src/source-contracts.mjs +106 -0
  51. package/src/sources.mjs +78 -0
  52. package/src/workbench-projects.mjs +118 -0
package/src/render.mjs ADDED
@@ -0,0 +1,330 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { doctorConfig, referencedFiles, registryPrivateScopes } from './config.mjs';
4
+ import { invariant } from './errors.mjs';
5
+ import { inventoryDirectory, sha256, stableJson } from './hash.mjs';
6
+ import { resolveProfile } from './profiles.mjs';
7
+ import { atomicWrite, resolveContained } from './security.mjs';
8
+
9
+ function makeDirectory(candidate, mode = 0o755) {
10
+ fs.mkdirSync(candidate, { recursive: true, mode });
11
+ const current = fs.lstatSync(candidate).mode & 0o777;
12
+ fs.chmodSync(candidate, current & mode);
13
+ }
14
+
15
+ function writeReadonly(candidate, content) {
16
+ makeDirectory(path.dirname(candidate));
17
+ atomicWrite(candidate, content, 0o444);
18
+ }
19
+
20
+ function copyReadonly(source, target) {
21
+ writeReadonly(target, fs.readFileSync(source));
22
+ }
23
+
24
+ function copySecret(source, target) {
25
+ makeDirectory(path.dirname(target), 0o700);
26
+ atomicWrite(target, fs.readFileSync(source), 0o400);
27
+ }
28
+
29
+ function caddyHandlers(active) {
30
+ const handlers = [
31
+ ' header {',
32
+ ' -Server',
33
+ ' X-Content-Type-Options nosniff',
34
+ ' X-Frame-Options DENY',
35
+ ' Referrer-Policy no-referrer',
36
+ ' }',
37
+ ' handle /live {',
38
+ ' respond "IDP edge ok" 200',
39
+ ' }',
40
+ ];
41
+ if (active.has('flow')) handlers.push(
42
+ ' handle /flow {',
43
+ ' redir /flow/ 308',
44
+ ' }',
45
+ ' handle_path /flow/* {',
46
+ ' reverse_proxy flow:3710',
47
+ ' }',
48
+ );
49
+ if (active.has('tech')) handlers.push(
50
+ ' handle /knowledge-workbench {',
51
+ ' redir /knowledge-workbench/ 308',
52
+ ' }',
53
+ ' handle /knowledge-workbench/* {',
54
+ ' reverse_proxy tech-workbench:3011',
55
+ ' }',
56
+ ' handle /knowledge {',
57
+ ' redir /knowledge/ 308',
58
+ ' }',
59
+ ' handle_path /knowledge/* {',
60
+ ' reverse_proxy tech:3698',
61
+ ' }',
62
+ );
63
+ if (active.has('smartgo')) handlers.push(
64
+ ' handle /smartgo/api {',
65
+ ' redir /smartgo/api/ 308',
66
+ ' }',
67
+ ' handle_path /smartgo/api/* {',
68
+ ' reverse_proxy smartgo-api:3200',
69
+ ' }',
70
+ ' handle /smartgo-artifacts/* {',
71
+ ' reverse_proxy smartgo-object-store:9000',
72
+ ' }',
73
+ );
74
+ if (active.has('portal')) handlers.push(
75
+ ' handle {',
76
+ ' reverse_proxy portal:7007',
77
+ ' }',
78
+ );
79
+ else if (active.has('tech')) handlers.push(
80
+ ' handle {',
81
+ ' reverse_proxy tech:3698',
82
+ ' }',
83
+ );
84
+ else handlers.push(
85
+ ' handle {',
86
+ ' respond "当前部署 Profile 没有 Web 门户" 404',
87
+ ' }',
88
+ );
89
+ return handlers;
90
+ }
91
+
92
+ function smartGoApplicationSite(address, upstream) {
93
+ return `${address} {
94
+ header {
95
+ -Server
96
+ X-Content-Type-Options nosniff
97
+ X-Frame-Options DENY
98
+ Referrer-Policy no-referrer
99
+ }
100
+ reverse_proxy ${upstream}
101
+ }`;
102
+ }
103
+
104
+ function siteBlock({ address, active, tls = false, redirectTo }) {
105
+ const lines = [`${address} {`];
106
+ if (redirectTo) lines.push(` redir ${redirectTo}{uri} permanent`);
107
+ else {
108
+ if (tls) lines.push(' tls /run/secrets/tls-certificate.pem /run/secrets/tls-private-key.pem');
109
+ lines.push(...caddyHandlers(active));
110
+ }
111
+ lines.push('}');
112
+ return lines.join('\n');
113
+ }
114
+
115
+ export function renderCaddyfile(env, active) {
116
+ const httpAddress = `http://${env.IDP_PUBLIC_HOST}:${env.IDP_HTTP_PORT}`;
117
+ const httpsAddress = `https://${env.IDP_PUBLIC_HOST}:${env.IDP_HTTPS_PORT}`;
118
+ const blocks = [
119
+ '{', ' admin off', ' auto_https off', '}',
120
+ 'http://127.0.0.1:2019 {',
121
+ ' handle /live {',
122
+ ' respond "IDP edge ok" 200',
123
+ ' }',
124
+ ' handle {',
125
+ ' respond "Not Found" 404',
126
+ ' }',
127
+ '}',
128
+ ];
129
+ if (env.IDP_TLS_ENABLED === 'true') {
130
+ if (env.IDP_HTTP_ENABLED === 'true') blocks.push(siteBlock({ address: httpAddress, active, redirectTo: httpsAddress }));
131
+ blocks.push(siteBlock({ address: httpsAddress, active, tls: true }));
132
+ } else {
133
+ blocks.push(siteBlock({ address: httpAddress, active }));
134
+ }
135
+ if (active.has('smartgo')) {
136
+ blocks.push(
137
+ smartGoApplicationSite(`http://:${env.IDP_SMARTGO_GOTOLOGY_PORT}`, 'smartgo-gotology:3000'),
138
+ smartGoApplicationSite(`http://:${env.IDP_SMARTGO_OPERATIONS_PORT}`, 'smartgo-operations:3101'),
139
+ smartGoApplicationSite(`http://:${env.IDP_SMARTGO_STUDIO_PORT}`, 'smartgo-studio:3002'),
140
+ );
141
+ }
142
+ return `${blocks.join('\n\n')}\n`;
143
+ }
144
+
145
+ export function renderVerdaccioConfig(privateScopes = ['@aipt', '@bench']) {
146
+ const scopes = privateScopes.map((scope) => {
147
+ invariant(/^@[a-z0-9][a-z0-9._-]{0,63}$/u.test(scope), 'IDP_REGISTRY_SCOPE_INVALID', `Registry私有Scope格式无效:${scope}`);
148
+ return ` '${scope}/*':\n access: $all\n publish: $authenticated\n unpublish: $authenticated`;
149
+ }).join('\n');
150
+ return `storage: /verdaccio/storage/packages
151
+ plugins: /verdaccio/plugins
152
+ auth:
153
+ htpasswd:
154
+ file: /verdaccio/storage/auth/htpasswd
155
+ max_users: 100
156
+ uplinks:
157
+ npmjs:
158
+ url: https://registry.npmjs.org/
159
+ packages:
160
+ ${scopes}
161
+ '@*/*':
162
+ access: $all
163
+ publish: $authenticated
164
+ unpublish: $authenticated
165
+ proxy: npmjs
166
+ '**':
167
+ access: $all
168
+ publish: $authenticated
169
+ unpublish: $authenticated
170
+ proxy: npmjs
171
+ web:
172
+ enable: true
173
+ log:
174
+ type: stdout
175
+ format: pretty
176
+ level: http
177
+ `;
178
+ }
179
+
180
+ function renderComponentBundle({ staging, root, component, componentEnvText, refs, additionalSensitiveRefs = [] }) {
181
+ // Flow 的已批准组件合同以 IDP_CONFIG_DIR 为部署配置根,并固定从
182
+ // $IDP_CONFIG_DIR/components/flow 读取;其单服务挂载仍只包含 Flow,
183
+ // 不能为了路径兼容而把整个运行时投影暴露给容器。
184
+ const target = component === 'flow'
185
+ ? path.join(staging, component, 'components', component)
186
+ : path.join(staging, component);
187
+ makeDirectory(target, 0o700);
188
+ const runtimeExcludedKeys = component === 'smartgo' ? new Set(['SMARTGO_NPMRC_FILE']) : new Set();
189
+ const runtimeEnvText = componentEnvText.split(/(?<=\n)/u).filter((line) => {
190
+ const key = line.match(/^([A-Z][A-Z0-9_]*)=/u)?.[1];
191
+ return !key || !runtimeExcludedKeys.has(key);
192
+ }).join('');
193
+ atomicWrite(path.join(target, '.env'), runtimeEnvText, 0o400);
194
+ const componentRefs = [
195
+ ...refs.filter((entry) => entry.scope === component && !runtimeExcludedKeys.has(entry.key)),
196
+ ...additionalSensitiveRefs,
197
+ ];
198
+ const projectedReferences = new Set();
199
+ for (const ref of componentRefs) {
200
+ if (!fs.existsSync(ref.absolute)) continue;
201
+ invariant(!projectedReferences.has(ref.reference), 'IDP_RUNTIME_PROJECTION_COLLISION', `${component}运行时投影路径重复:${ref.reference}`);
202
+ projectedReferences.add(ref.reference);
203
+ const sensitive = ref.sensitive === true || ref.key?.endsWith('_FILE');
204
+ const targetFile = path.join(target, ref.reference);
205
+ makeDirectory(path.dirname(targetFile), 0o700);
206
+ atomicWrite(targetFile, fs.readFileSync(ref.absolute), sensitive ? 0o400 : 0o444);
207
+ }
208
+ const componentConfig = path.join(root, 'components', component, 'config');
209
+ if (fs.existsSync(componentConfig)) {
210
+ for (const entry of fs.readdirSync(componentConfig, { withFileTypes: true })) {
211
+ if (!entry.isFile()) continue;
212
+ const reference = `config/${entry.name}`;
213
+ if (projectedReferences.has(reference)) continue;
214
+ copyReadonly(path.join(componentConfig, entry.name), path.join(target, reference));
215
+ }
216
+ }
217
+ }
218
+
219
+ function readGeneratedManifest(target) {
220
+ const candidate = path.join(target, 'manifest.json');
221
+ invariant(fs.existsSync(candidate), 'IDP_RENDER_GENERATION_INVALID', `运行时生成代次缺少manifest.json:${target}`);
222
+ let manifest;
223
+ try { manifest = JSON.parse(fs.readFileSync(candidate, 'utf8')); }
224
+ catch { invariant(false, 'IDP_RENDER_GENERATION_INVALID', `运行时生成代次manifest不是有效JSON:${target}`); }
225
+ const exactKeys = ['schemaVersion', 'profile', 'configDigest', 'generatedAt', 'files', 'digest'];
226
+ invariant(
227
+ manifest && typeof manifest === 'object' && !Array.isArray(manifest) &&
228
+ stableJson(Object.keys(manifest).sort()) === stableJson(exactKeys.sort()),
229
+ 'IDP_RENDER_GENERATION_INVALID', `运行时生成代次manifest字段无效:${target}`,
230
+ );
231
+ const generatedAt = typeof manifest.generatedAt === 'string' ? new Date(manifest.generatedAt) : new Date(Number.NaN);
232
+ const { generatedAt: _generatedAt, digest, ...facts } = manifest;
233
+ invariant(
234
+ manifest.schemaVersion === 1 && typeof manifest.profile === 'string' &&
235
+ /^sha256:[0-9a-f]{64}$/u.test(manifest.configDigest ?? '') &&
236
+ !Number.isNaN(generatedAt.getTime()) && generatedAt.toISOString() === manifest.generatedAt &&
237
+ Array.isArray(manifest.files) && /^sha256:[0-9a-f]{64}$/u.test(digest ?? '') &&
238
+ sha256(stableJson(facts)) === digest,
239
+ 'IDP_RENDER_GENERATION_INVALID', `运行时生成代次manifest事实摘要无效:${target}`,
240
+ );
241
+ const actualFiles = inventoryDirectory(target)
242
+ .filter(({ path: filePath }) => filePath !== 'manifest.json')
243
+ .map(({ path: filePath, mode, size, digest: fileDigest }) => ({ path: filePath, mode, size, digest: fileDigest }));
244
+ invariant(stableJson(actualFiles) === stableJson(manifest.files), 'IDP_RENDER_GENERATION_INVALID', `运行时生成代次文件与manifest不一致:${target}`);
245
+ return manifest;
246
+ }
247
+
248
+ export function readRuntimeGenerationManifest(runtimeRoot, profile, digest) {
249
+ invariant(/^sha256:[0-9a-f]{64}$/u.test(digest ?? ''), 'IDP_RENDER_GENERATION_INVALID', '运行时生成代次digest无效');
250
+ const target = resolveContained(
251
+ runtimeRoot,
252
+ `rendered/${profile}/generations/${digest.slice('sha256:'.length)}`,
253
+ '运行时生成代次',
254
+ );
255
+ return readGeneratedManifest(target);
256
+ }
257
+
258
+ function publishGeneratedDirectory(runtimeRoot, staging, target, expectedManifest) {
259
+ const relative = path.relative(runtimeRoot, target);
260
+ invariant(relative.startsWith('rendered/') && !relative.includes('..'), 'IDP_RENDER_TARGET_INVALID', '生成目录必须位于Runtime Root的rendered目录');
261
+ makeDirectory(path.dirname(target), 0o700);
262
+ if (fs.existsSync(target)) {
263
+ const existing = readGeneratedManifest(target);
264
+ invariant(existing.digest === expectedManifest.digest, 'IDP_RENDER_GENERATION_CONFLICT', '相同运行时生成代次路径包含不同事实');
265
+ fs.rmSync(staging, { recursive: true, force: true });
266
+ return existing;
267
+ }
268
+ fs.renameSync(staging, target);
269
+ return expectedManifest;
270
+ }
271
+
272
+ export function renderRuntimeBundles(configRoot, profile) {
273
+ const { root, env, components, report, techSourceCredentials } = doctorConfig(configRoot, { requireConfigured: false, profile });
274
+ const active = new Set(resolveProfile(profile));
275
+ const runtimeRoot = env.IDP_RUNTIME_DIR;
276
+ makeDirectory(runtimeRoot, 0o700);
277
+ makeDirectory(path.join(runtimeRoot, 'tmp'), 0o700);
278
+ const staging = resolveContained(runtimeRoot, `tmp/render-${profile}-${process.pid}-${Date.now()}`, '生成暂存目录');
279
+ makeDirectory(staging, 0o700);
280
+ try {
281
+ const refs = referencedFiles(root);
282
+ if (active.has('postgresql')) {
283
+ const bootstrapRefs = [
284
+ ['IDP_POSTGRES_SUPERUSER_PASSWORD_FILE', 'superuser-password'],
285
+ ['IDP_POSTGRES_TECH_PASSWORD_FILE', 'tech-password'],
286
+ ['IDP_POSTGRES_PORTAL_PASSWORD_FILE', 'portal-password'],
287
+ ...(active.has('smartgo') ? [['IDP_POSTGRES_SMARTGO_PASSWORD_FILE', 'smartgo-password']] : []),
288
+ ];
289
+ for (const [key, filename] of bootstrapRefs) {
290
+ const ref = refs.find((entry) => entry.scope === 'root' && entry.key === key);
291
+ invariant(ref && fs.existsSync(ref.absolute), 'IDP_RUNTIME_PROJECTION_SOURCE_MISSING', `PostgreSQL Bootstrap缺少${key}`);
292
+ copySecret(ref.absolute, path.join(staging, 'postgresql-bootstrap', filename));
293
+ }
294
+ }
295
+ for (const component of ['tech', 'flow', 'portal', 'smartgo']) {
296
+ if (!active.has(component)) continue;
297
+ renderComponentBundle({
298
+ staging,
299
+ root,
300
+ component,
301
+ componentEnvText: fs.readFileSync(path.join(root, 'components', component, '.env'), 'utf8'),
302
+ refs,
303
+ additionalSensitiveRefs: component === 'tech'
304
+ ? techSourceCredentials.files.map((entry) => ({ ...entry, sensitive: true }))
305
+ : [],
306
+ });
307
+ }
308
+ if (active.has('edge')) {
309
+ writeReadonly(path.join(staging, 'edge', 'Caddyfile'), renderCaddyfile(env, active));
310
+ }
311
+ if (active.has('registry')) {
312
+ writeReadonly(path.join(staging, 'registry', 'config.yaml'), renderVerdaccioConfig(registryPrivateScopes(env)));
313
+ }
314
+ const inventory = inventoryDirectory(staging);
315
+ const facts = {
316
+ schemaVersion: 1,
317
+ profile,
318
+ configDigest: report.digest,
319
+ files: inventory.map(({ path: filePath, mode, size, digest }) => ({ path: filePath, mode, size, digest })),
320
+ };
321
+ const manifest = { ...facts, generatedAt: new Date().toISOString(), digest: sha256(stableJson(facts)) };
322
+ writeReadonly(path.join(staging, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
323
+ const target = resolveContained(runtimeRoot, `rendered/${profile}/generations/${manifest.digest.slice('sha256:'.length)}`, '生成目标代次');
324
+ const publishedManifest = publishGeneratedDirectory(runtimeRoot, staging, target, manifest);
325
+ return { target, manifest: publishedManifest };
326
+ } catch (error) {
327
+ if (fs.existsSync(staging)) fs.rmSync(staging, { recursive: true, force: true });
328
+ throw error;
329
+ }
330
+ }
@@ -0,0 +1,156 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { IdpError, invariant } from './errors.mjs';
4
+
5
+ export function parseEnv(text, source = '.env') {
6
+ const result = Object.create(null);
7
+ for (const [index, raw] of text.split(/\r?\n/u).entries()) {
8
+ const line = raw.trim();
9
+ if (!line || line.startsWith('#')) continue;
10
+ invariant(!line.startsWith('export '), 'IDP_ENV_EXPORT_FORBIDDEN', `${source}:${index + 1} 不允许使用 export`);
11
+ const match = /^([A-Z][A-Z0-9_]*)=(.*)$/u.exec(line);
12
+ invariant(match, 'IDP_ENV_INVALID', `${source}:${index + 1} 不是严格 KEY=VALUE 格式`);
13
+ const [, key, rawValue] = match;
14
+ invariant(!(key in result), 'IDP_ENV_DUPLICATE', `${source}:${index + 1} 重复变量 ${key}`);
15
+ let value = rawValue.trim();
16
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
17
+ value = value.slice(1, -1);
18
+ }
19
+ invariant(!/[`$][({]?/u.test(value), 'IDP_ENV_EXPANSION_FORBIDDEN', `${source}:${index + 1} 不允许命令或变量展开`);
20
+ result[key] = value;
21
+ }
22
+ return result;
23
+ }
24
+
25
+ export function findGitAncestor(candidate) {
26
+ let cursor = path.resolve(candidate);
27
+ for (;;) {
28
+ if (fs.existsSync(path.join(cursor, '.git'))) return cursor;
29
+ const parent = path.dirname(cursor);
30
+ if (parent === cursor) return undefined;
31
+ cursor = parent;
32
+ }
33
+ }
34
+
35
+ export function canonicalExisting(candidate, role = '路径') {
36
+ invariant(path.isAbsolute(candidate), 'IDP_PATH_NOT_ABSOLUTE', `${role}必须是绝对路径`);
37
+ const stat = fs.lstatSync(candidate);
38
+ invariant(!stat.isSymbolicLink(), 'IDP_PATH_SYMLINK', `${role}不能是符号链接`);
39
+ return fs.realpathSync.native(candidate);
40
+ }
41
+
42
+ export function canonicalPlannedDirectory(candidate, role = '目录') {
43
+ invariant(path.isAbsolute(candidate), 'IDP_PATH_NOT_ABSOLUTE', `${role}必须是绝对路径`);
44
+ let cursor = path.resolve(candidate);
45
+ const suffix = [];
46
+ while (!fs.existsSync(cursor)) {
47
+ const parent = path.dirname(cursor);
48
+ invariant(parent !== cursor, 'IDP_DIRECTORY_ANCESTOR_MISSING', `${role}找不到已存在的父目录`);
49
+ suffix.unshift(path.basename(cursor));
50
+ cursor = parent;
51
+ }
52
+ const stat = fs.lstatSync(cursor);
53
+ invariant(!stat.isSymbolicLink(), 'IDP_PATH_SYMLINK', `${role}的最近已存在路径不能是符号链接:${cursor}`);
54
+ invariant(stat.isDirectory(), 'IDP_DIRECTORY_INVALID', `${role}的父路径必须是目录:${cursor}`);
55
+ return path.join(fs.realpathSync.native(cursor), ...suffix);
56
+ }
57
+
58
+ export function secureDirectoryRoot(candidate, role = '目录', { create = false, allowForeignOwner = false } = {}) {
59
+ const planned = canonicalPlannedDirectory(candidate, role);
60
+ if (create && !fs.existsSync(planned)) fs.mkdirSync(planned, { recursive: true, mode: 0o700 });
61
+ if (!fs.existsSync(planned)) return planned;
62
+ const stat = fs.lstatSync(planned);
63
+ invariant(stat.isDirectory() && !stat.isSymbolicLink(), 'IDP_DIRECTORY_INVALID', `${role}必须是普通目录:${planned}`);
64
+ if (!allowForeignOwner && typeof process.getuid === 'function') invariant(stat.uid === process.getuid(), 'IDP_DIRECTORY_OWNER_INVALID', `${role}必须由当前部署用户拥有:${planned}`);
65
+ if (create) fs.chmodSync(planned, 0o700);
66
+ assertMode(planned, 0o700, role);
67
+ return fs.realpathSync.native(planned);
68
+ }
69
+
70
+ export function assertOutsideRepositories(candidate, role = '路径') {
71
+ const gitRoot = findGitAncestor(candidate);
72
+ invariant(!gitRoot, 'IDP_PATH_INSIDE_REPOSITORY', `${role}不能位于Git仓库内:${gitRoot}`);
73
+ }
74
+
75
+ export function assertMode(candidate, maximumMode, role = '文件') {
76
+ const stat = fs.lstatSync(candidate);
77
+ const actual = stat.mode & 0o777;
78
+ invariant((actual & ~maximumMode) === 0, 'IDP_MODE_TOO_OPEN', `${role}权限过宽:${actual.toString(8)},最大允许${maximumMode.toString(8)}`);
79
+ }
80
+
81
+ export function assertSafeDirectory(candidate, maximumMode = 0o700, { role = '目录' } = {}) {
82
+ const stat = fs.lstatSync(candidate);
83
+ invariant(stat.isDirectory() && !stat.isSymbolicLink(), 'IDP_DIRECTORY_INVALID', `${role}必须是普通目录:${candidate}`);
84
+ assertMode(candidate, maximumMode, role);
85
+ return stat;
86
+ }
87
+
88
+ export function assertSafeRegularFile(candidate, maximumMode = 0o600, { allowEmpty = true, role = '文件' } = {}) {
89
+ const stat = fs.lstatSync(candidate);
90
+ invariant(stat.isFile(), 'IDP_FILE_NOT_REGULAR', `${role}必须是普通文件:${candidate}`);
91
+ invariant(!stat.isSymbolicLink(), 'IDP_FILE_SYMLINK', `${role}不能是符号链接:${candidate}`);
92
+ invariant(stat.nlink === 1, 'IDP_FILE_HARDLINK', `${role}必须只有一个硬链接:${candidate}`);
93
+ assertMode(candidate, maximumMode, role);
94
+ if (!allowEmpty) invariant(stat.size > 0, 'IDP_FILE_EMPTY', `${role}尚未配置:${candidate}`);
95
+ return stat;
96
+ }
97
+
98
+ export function resolveContained(root, reference, role = '文件引用') {
99
+ invariant(reference && !path.isAbsolute(reference), 'IDP_REF_NOT_RELATIVE', `${role}必须是相对路径`);
100
+ invariant(!reference.includes('\0'), 'IDP_REF_NUL', `${role}包含非法NUL`);
101
+ invariant(!/[*?{}[\]]/u.test(reference), 'IDP_REF_GLOB', `${role}不允许glob`);
102
+ const resolved = path.resolve(root, reference);
103
+ const relative = path.relative(root, resolved);
104
+ invariant(relative && !relative.startsWith('..') && !path.isAbsolute(relative), 'IDP_REF_ESCAPE', `${role}逃逸Config Root:${reference}`);
105
+ let cursor = root;
106
+ for (const segment of relative.split(path.sep)) {
107
+ cursor = path.join(cursor, segment);
108
+ if (!fs.existsSync(cursor)) break;
109
+ invariant(!fs.lstatSync(cursor).isSymbolicLink(), 'IDP_REF_SYMLINK', `${role}的路径中不允许符号链接:${reference}`);
110
+ }
111
+ return resolved;
112
+ }
113
+
114
+ export function assertDisjointRoots(entries) {
115
+ for (let left = 0; left < entries.length; left += 1) {
116
+ for (let right = left + 1; right < entries.length; right += 1) {
117
+ const [leftRole, leftPath] = entries[left];
118
+ const [rightRole, rightPath] = entries[right];
119
+ const relLR = path.relative(leftPath, rightPath);
120
+ const relRL = path.relative(rightPath, leftPath);
121
+ const nested = relLR === '' || (!relLR.startsWith('..') && !path.isAbsolute(relLR)) || (!relRL.startsWith('..') && !path.isAbsolute(relRL));
122
+ invariant(!nested, 'IDP_ROOTS_OVERLAP', `${leftRole}与${rightRole}不能相同或互相嵌套`);
123
+ }
124
+ }
125
+ }
126
+
127
+ export function createExclusiveFile(candidate, content = '', mode = 0o600) {
128
+ const handle = fs.openSync(candidate, 'wx', mode);
129
+ let succeeded = false;
130
+ try {
131
+ fs.writeFileSync(handle, content, { encoding: 'utf8' });
132
+ fs.fsyncSync(handle);
133
+ succeeded = true;
134
+ } finally {
135
+ fs.closeSync(handle);
136
+ if (!succeeded) {
137
+ try { fs.unlinkSync(candidate); } catch {}
138
+ }
139
+ }
140
+ }
141
+
142
+ export function atomicWrite(candidate, content, mode = 0o600) {
143
+ const parent = path.dirname(candidate);
144
+ invariant(fs.existsSync(parent) && fs.lstatSync(parent).isDirectory() && !fs.lstatSync(parent).isSymbolicLink(), 'IDP_ATOMIC_PARENT_INVALID', `原子写入父目录无效:${parent}`);
145
+ const temp = path.join(parent, `.${path.basename(candidate)}.${process.pid}.${Date.now()}.tmp`);
146
+ createExclusiveFile(temp, content, mode);
147
+ try {
148
+ fs.renameSync(temp, candidate);
149
+ fs.chmodSync(candidate, mode);
150
+ const parentHandle = fs.openSync(parent, 'r');
151
+ try { fs.fsyncSync(parentHandle); } finally { fs.closeSync(parentHandle); }
152
+ } catch (error) {
153
+ try { fs.unlinkSync(temp); } catch {}
154
+ throw new IdpError('IDP_ATOMIC_WRITE_FAILED', `原子写入失败:${candidate}`, { cause: error.message });
155
+ }
156
+ }
@@ -0,0 +1,106 @@
1
+ import { invariant } from './errors.mjs';
2
+ import { sha256, stableJson } from './hash.mjs';
3
+
4
+ const DIGEST = /^sha256:[a-f0-9]{64}$/u;
5
+ const VERSION = /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$/u;
6
+ const PACKAGE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u;
7
+ const CAPABILITY_REF = /^capability:[a-z0-9][a-z0-9.-]*@[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/u;
8
+
9
+ function record(value) { return typeof value === 'object' && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype; }
10
+ function exactKeys(value, expected, role) {
11
+ invariant(record(value), 'IDP_SOURCE_SCHEMA_INVALID', `${role}必须是对象`);
12
+ const actual = Object.keys(value).sort(); const wanted = [...expected].sort();
13
+ invariant(actual.length === wanted.length && actual.every((key, index) => key === wanted[index]), 'IDP_SOURCE_SCHEMA_INVALID', `${role}字段不符合严格合同`);
14
+ }
15
+ function sortedUniqueStrings(value, predicate = () => true) {
16
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string' && predicate(entry)) && value.every((entry, index) => index === 0 || value[index - 1] < entry);
17
+ }
18
+ function canonicalTimestamp(value, role) {
19
+ const date = new Date(value);
20
+ invariant(typeof value === 'string' && !Number.isNaN(date.getTime()) && date.toISOString() === value, 'IDP_SOURCE_SCHEMA_INVALID', `${role}必须是规范ISO-8601 UTC时间`);
21
+ }
22
+
23
+ export function validateDyytoSnapshot(document) {
24
+ exactKeys(document, ['schemaVersion', 'generatedAt', 'sourceDigest', 'items'], 'Dyyto Snapshot');
25
+ invariant(document.schemaVersion === 1 && DIGEST.test(document.sourceDigest ?? '') && Array.isArray(document.items) && document.items.length <= 20_000, 'IDP_SOURCE_SCHEMA_INVALID', 'Dyyto Snapshot顶层字段无效');
26
+ canonicalTimestamp(document.generatedAt, 'generatedAt');
27
+ const refs = new Set(); let previous = '';
28
+ for (const [index, item] of document.items.entries()) {
29
+ exactKeys(item, ['kind', 'ref', 'packageName', 'version', 'releaseDigest', 'capabilities'], `Dyyto items[${index}]`);
30
+ invariant(item.kind === 'package' && PACKAGE.test(item.packageName ?? '') && VERSION.test(item.version ?? '') && DIGEST.test(item.releaseDigest ?? ''), 'IDP_SOURCE_SCHEMA_INVALID', `Dyyto items[${index}]字段无效`);
31
+ invariant(item.ref === `package:${item.packageName}@${item.version}` && item.ref > previous && !refs.has(item.ref), 'IDP_SOURCE_DUPLICATE', `Dyyto Package ref必须唯一且按字典序排列:${item.ref}`);
32
+ invariant(sortedUniqueStrings(item.capabilities, (value) => CAPABILITY_REF.test(value)), 'IDP_SOURCE_SCHEMA_INVALID', `Dyyto ${item.ref} capabilities必须是排序唯一的精确ref`);
33
+ previous = item.ref; refs.add(item.ref);
34
+ }
35
+ const { sourceDigest, ...body } = document;
36
+ invariant(sha256(stableJson(body)) === sourceDigest, 'IDP_SOURCE_DIGEST_MISMATCH', 'Dyyto Snapshot sourceDigest不匹配');
37
+ return document;
38
+ }
39
+
40
+ const FILE_URI = /(?:^|[^a-zA-Z0-9])file:\/{2,3}[^\s"'<>]+/iu;
41
+ const HOME_PATH = /(?:^|[^a-zA-Z0-9])~[\\/][^\s"'<>]*/u;
42
+ const POSIX_ABSOLUTE = /(?:^|[^a-zA-Z0-9:/])\/(?!\/)[^\s"'<>]*/u;
43
+ const UNC_PATH = /(?:^|[^a-zA-Z0-9:/])(?:\\\\|\/\/)[^\\/\s"'<>]+[\\/][^\s"'<>]+/u;
44
+ const WINDOWS_ABSOLUTE = /(?:^|[^a-zA-Z0-9])[a-zA-Z]:[\\/][^\s"'<>]*/u;
45
+
46
+ function assertNoHostPaths(value) {
47
+ let scannedStringCount = 0;
48
+ const visit = (current) => {
49
+ if (typeof current === 'string') {
50
+ scannedStringCount += 1;
51
+ invariant(![FILE_URI, HOME_PATH, POSIX_ABSOLUTE, UNC_PATH, WINDOWS_ABSOLUTE].some((pattern) => pattern.test(current)), 'IDP_SOURCE_HOST_PATH', 'Bench Snapshot包含宿主机绝对路径');
52
+ }
53
+ else if (Array.isArray(current)) current.forEach(visit);
54
+ else if (record(current)) Object.entries(current).forEach(([key, child]) => { visit(key); visit(child); });
55
+ };
56
+ visit(value);
57
+ return scannedStringCount;
58
+ }
59
+
60
+ function assertNoInlineSecrets(configuration) {
61
+ const visit = (current) => {
62
+ if (Array.isArray(current)) return current.forEach(visit);
63
+ if (!record(current)) return;
64
+ for (const [key, value] of Object.entries(current)) {
65
+ invariant(!/(password|secret|token|api[-_]?key|access[-_]?key|private[-_]?key)/iu.test(key), 'IDP_SOURCE_INLINE_SECRET', `Bench configuration禁止内联敏感字段:${key}`);
66
+ visit(value);
67
+ }
68
+ };
69
+ visit(configuration);
70
+ }
71
+
72
+ export function validateBenchSnapshot(document) {
73
+ exactKeys(document, ['schemaVersion', 'environment', 'catalogDigest', 'policy', 'availableCapabilities', 'bindings', 'snapshotDigest'], 'Bench Snapshot');
74
+ invariant(document.schemaVersion === 'bench.environment-snapshot/v1' && /^[a-z][a-z0-9-]*(?:\.[a-z0-9-]+)*$/u.test(document.environment ?? '') && DIGEST.test(document.catalogDigest ?? '') && DIGEST.test(document.snapshotDigest ?? ''), 'IDP_SOURCE_SCHEMA_INVALID', 'Bench Snapshot顶层字段无效');
75
+ exactKeys(document.policy, ['configurationRootVariable', 'fileReferencesVerified', 'secretValuesExposed', 'hostPathsExposed', 'hostPathScan', 'provisioningAuthority'], 'Bench policy');
76
+ exactKeys(document.policy.hostPathScan, ['detector', 'scannedStringCount', 'matchCount'], 'Bench hostPathScan');
77
+ invariant(document.policy.configurationRootVariable === 'IDP_CONFIG_DIR' && document.policy.fileReferencesVerified === true && document.policy.secretValuesExposed === false && document.policy.hostPathsExposed === false && document.policy.provisioningAuthority === 'idp-deploy', 'IDP_SOURCE_POLICY_INVALID', 'Bench Snapshot安全策略证据无效');
78
+ invariant(document.policy.hostPathScan.detector === 'bench.host-path/v1' && Number.isSafeInteger(document.policy.hostPathScan.scannedStringCount) && document.policy.hostPathScan.scannedStringCount >= 0 && document.policy.hostPathScan.matchCount === 0, 'IDP_SOURCE_POLICY_INVALID', 'Bench hostPathScan证据无效');
79
+ invariant(sortedUniqueStrings(document.availableCapabilities), 'IDP_SOURCE_SCHEMA_INVALID', 'Bench availableCapabilities必须排序且唯一');
80
+ invariant(Array.isArray(document.bindings) && document.bindings.length <= 20_000, 'IDP_SOURCE_SCHEMA_INVALID', 'Bench bindings必须是有界数组');
81
+ const bindingIds = new Set(); let previous = '';
82
+ for (const [index, binding] of document.bindings.entries()) {
83
+ exactKeys(binding, ['id', 'consumer', 'capability', 'offering', 'profile', 'configuration', 'secretRefNames', 'certificateRefNames', 'trustRefNames'], `Bench bindings[${index}]`);
84
+ invariant([binding.id, binding.consumer, binding.capability, binding.offering, binding.profile].every((value) => typeof value === 'string' && value.length > 0 && value.length <= 256), 'IDP_SOURCE_SCHEMA_INVALID', `Bench bindings[${index}]标识无效`);
85
+ invariant(binding.id > previous && !bindingIds.has(binding.id), 'IDP_SOURCE_DUPLICATE', `Bench Binding id必须唯一且按字典序排列:${binding.id}`);
86
+ invariant(record(binding.configuration) && sortedUniqueStrings(binding.secretRefNames) && sortedUniqueStrings(binding.certificateRefNames) && sortedUniqueStrings(binding.trustRefNames), 'IDP_SOURCE_SCHEMA_INVALID', `Bench Binding ${binding.id}配置无效`);
87
+ invariant(document.availableCapabilities.includes(binding.capability), 'IDP_SOURCE_SCHEMA_INVALID', `Bench Binding ${binding.id}引用未声明的Capability`);
88
+ assertNoInlineSecrets(binding.configuration);
89
+ previous = binding.id; bindingIds.add(binding.id);
90
+ }
91
+ const { hostPathsExposed: _hostPathsExposed, hostPathScan: _hostPathScan, ...policyScanSubject } = document.policy;
92
+ const scanSubject = {
93
+ schemaVersion: document.schemaVersion,
94
+ environment: document.environment,
95
+ catalogDigest: document.catalogDigest,
96
+ policy: policyScanSubject,
97
+ availableCapabilities: document.availableCapabilities,
98
+ bindings: document.bindings,
99
+ };
100
+ const scannedStringCount = assertNoHostPaths(scanSubject);
101
+ invariant(document.policy.hostPathScan.scannedStringCount === scannedStringCount, 'IDP_SOURCE_POLICY_INVALID', 'Bench hostPathScan扫描计数与内容不一致');
102
+ assertNoHostPaths(document);
103
+ const { snapshotDigest, ...body } = document;
104
+ invariant(sha256(stableJson(body)) === snapshotDigest, 'IDP_SOURCE_DIGEST_MISMATCH', 'Bench Snapshot snapshotDigest不匹配');
105
+ return document;
106
+ }