@synopackageland/cli 0.3.1 → 0.3.3

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 (69) hide show
  1. package/dist/attestation/app-package.js +8 -1
  2. package/dist/attestation/platform-service.d.ts +70 -0
  3. package/dist/attestation/platform-service.js +250 -0
  4. package/dist/bin.js +0 -0
  5. package/dist/build-spk/generator.js +200 -10
  6. package/dist/build-spk/resource-worker.d.ts +14 -0
  7. package/dist/build-spk/resource-worker.js +140 -0
  8. package/dist/cli.js +69 -10
  9. package/dist/compose/analyze.d.ts +42 -1
  10. package/dist/compose/analyze.js +482 -18
  11. package/dist/contract/load.d.ts +1 -0
  12. package/dist/contract/load.js +1 -1
  13. package/dist/contract/posix-all-shares.d.ts +16 -0
  14. package/dist/contract/posix-all-shares.js +63 -0
  15. package/dist/contract/schema-validate.js +26 -1
  16. package/dist/contract/validate.js +57 -0
  17. package/dist/contract/web-launch.d.ts +6 -0
  18. package/dist/contract/web-launch.js +25 -0
  19. package/dist/deploy/deploy.d.ts +1 -0
  20. package/dist/deploy/deploy.js +94 -2
  21. package/dist/deploy/homes-values.d.ts +19 -0
  22. package/dist/deploy/homes-values.js +29 -0
  23. package/dist/deploy/param-values.d.ts +21 -0
  24. package/dist/deploy/param-values.js +51 -0
  25. package/dist/deploy/spk-version.d.ts +13 -0
  26. package/dist/deploy/spk-version.js +80 -27
  27. package/dist/doctor.d.ts +22 -0
  28. package/dist/doctor.js +594 -0
  29. package/dist/error-collector.d.ts +2 -0
  30. package/dist/error-collector.js +46 -0
  31. package/dist/error-registry.d.ts +7 -0
  32. package/dist/error-registry.js +837 -0
  33. package/dist/host/broker-upgrade-preflight.d.ts +61 -0
  34. package/dist/host/broker-upgrade-preflight.js +156 -0
  35. package/dist/host/catalog-install.d.ts +20 -0
  36. package/dist/host/catalog-install.js +143 -17
  37. package/dist/host/credentials.js +35 -5
  38. package/dist/host/dsm-client.d.ts +12 -0
  39. package/dist/host/dsm-client.js +69 -1
  40. package/dist/host/dsm-compound.d.ts +37 -0
  41. package/dist/host/dsm-compound.js +120 -0
  42. package/dist/host/fake-nas.d.ts +1 -0
  43. package/dist/host/fake-nas.js +3 -0
  44. package/dist/host/types.d.ts +6 -0
  45. package/dist/publish/curated-release.js +8 -4
  46. package/dist/publish/platform-service-release.d.ts +60 -0
  47. package/dist/publish/platform-service-release.js +333 -0
  48. package/dist/publish/preflight.js +24 -2
  49. package/dist/release-bin.js +5 -1
  50. package/dist/templates/hello-files/compose.yaml +2 -0
  51. package/dist/templates/hello-web/compose.yaml +14 -3
  52. package/dist/templates/hello-web/synology-app.yaml +1 -1
  53. package/dist/templates/hello-web-page/compose.yaml +14 -3
  54. package/dist/templates/hello-web-page/synology-app.yaml +1 -1
  55. package/dist/test-host.d.ts +2 -0
  56. package/dist/test-host.js +15 -2
  57. package/dist/types.d.ts +1 -1
  58. package/dist/validate.js +14 -3
  59. package/package.json +2 -2
  60. package/schema/doctor.schema.json +38 -0
  61. package/schema/synology-app.schema.json +1 -1
  62. package/skills/reference/create.md +19 -0
  63. package/skills/reference/diagnose.md +35 -2
  64. package/skills/reference/files.md +10 -0
  65. package/templates/hello-files/compose.yaml +2 -0
  66. package/templates/hello-web/compose.yaml +14 -3
  67. package/templates/hello-web/synology-app.yaml +1 -1
  68. package/templates/hello-web-page/compose.yaml +14 -3
  69. package/templates/hello-web-page/synology-app.yaml +1 -1
@@ -1,14 +1,27 @@
1
- import { existsSync } from "node:fs";
2
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
2
+ import { dirname, join, relative, resolve, sep } from "node:path";
3
3
  import { parseDocument } from "yaml";
4
4
  import { composeSpan, makeError } from "../errors.js";
5
+ import { collectInstallParameters, loadContract } from "../contract/load.js";
5
6
  import { undeclaredPrivilegeFindings } from "./privilege.js";
7
+ import { scanComposePrivilege } from "./privilege.js";
6
8
  const DOCKER_SOCKET_PATHS = [
7
9
  "/var/run/docker.sock",
8
10
  "/var/run/docker.sock/",
9
11
  "/var/run/containers/container-manager.sock",
10
12
  "/var/run/containers/container-manager.sock/",
11
13
  ];
14
+ const composeProjectRoots = new WeakMap();
15
+ const composeSources = new WeakMap();
16
+ const composeDocuments = new WeakMap();
17
+ const composeContracts = new WeakMap();
18
+ const HOST_BIND_ERROR_CODES = new Set([
19
+ "COMPOSE_BIND_SOURCE_FORBIDDEN",
20
+ "COMPOSE_BIND_SOURCE_UNDECLARED",
21
+ "COMPOSE_BIND_SUFFIX_INVALID",
22
+ "COMPOSE_HOST_BIND_FORBIDDEN",
23
+ "COMPOSE_NAMED_VOLUME_UNDECLARED",
24
+ ]);
12
25
  export function loadCompose(composePath) {
13
26
  if (!existsSync(composePath)) {
14
27
  return null;
@@ -16,7 +29,21 @@ export function loadCompose(composePath) {
16
29
  const source = readFileSync(composePath, "utf8");
17
30
  const doc = parseDocument(source);
18
31
  const compose = (doc.toJSON() ?? {});
19
- return { compose, source };
32
+ if (compose && typeof compose === "object") {
33
+ composeProjectRoots.set(compose, dirname(resolve(composePath)));
34
+ composeSources.set(compose, source);
35
+ composeDocuments.set(compose, doc);
36
+ const contractPath = join(dirname(resolve(composePath)), "synology-app.yaml");
37
+ if (existsSync(contractPath)) {
38
+ try {
39
+ composeContracts.set(compose, loadContract(contractPath).contract);
40
+ }
41
+ catch {
42
+ // Contract validation reports malformed input; Compose analysis still returns its own findings.
43
+ }
44
+ }
45
+ }
46
+ return { compose, source, document: doc };
20
47
  }
21
48
  export function listServiceNames(compose) {
22
49
  if (!compose.services || typeof compose.services !== "object") {
@@ -77,6 +104,322 @@ export function collectPublishedTcpPorts(compose) {
77
104
  }
78
105
  return result;
79
106
  }
107
+ /**
108
+ * Checks if a Compose contract and document are eligible for the zero-config DSM resource-worker path.
109
+ * Zero-config slice rejects:
110
+ * - any install parameter
111
+ * - any ${...} interpolation anywhere in compose document
112
+ * - compose secrets or configs
113
+ * - build: stanzas
114
+ * - privileged / host network / device / root-like capabilities
115
+ * - any App without a unique literal published adminport status probe
116
+ */
117
+ export function resourceWorkerEligibility(contract, compose) {
118
+ const reasons = [];
119
+ if (contract.install && Object.keys(contract.install).length > 0) {
120
+ reasons.push("HAS_INSTALL_PARAMETERS");
121
+ }
122
+ if (hasInterpolation(compose)) {
123
+ reasons.push("HAS_COMPOSE_INTERPOLATION");
124
+ }
125
+ if ((compose.secrets && Object.keys(compose.secrets).length > 0) ||
126
+ (compose.configs && typeof compose.configs === "object" && Object.keys(compose.configs).length > 0)) {
127
+ reasons.push("HAS_COMPOSE_SECRETS_OR_CONFIGS");
128
+ }
129
+ if (hasBuildStanza(compose)) {
130
+ reasons.push("HAS_BUILD_STANZA");
131
+ }
132
+ if (scanComposePrivilege(compose).length > 0) {
133
+ reasons.push("HAS_PRIVILEGED_CAPABILITY");
134
+ }
135
+ let resolvedAdminport;
136
+ if (contract.adminport === undefined) {
137
+ reasons.push("WEB_APP_MISSING_OR_AMBIGUOUS_ADMINPORT");
138
+ }
139
+ else {
140
+ const rawPort = Number(contract.adminport);
141
+ if (!Number.isInteger(rawPort) || rawPort <= 0 || rawPort > 65535) {
142
+ reasons.push("WEB_APP_MISSING_OR_AMBIGUOUS_ADMINPORT");
143
+ }
144
+ else {
145
+ const published = collectPublishedTcpPorts(compose);
146
+ const services = published.get(rawPort) ?? [];
147
+ if (services.length !== 1) {
148
+ reasons.push("WEB_APP_MISSING_OR_AMBIGUOUS_ADMINPORT");
149
+ }
150
+ else {
151
+ resolvedAdminport = rawPort;
152
+ }
153
+ }
154
+ }
155
+ return {
156
+ eligible: reasons.length === 0,
157
+ reasons,
158
+ adminport: resolvedAdminport,
159
+ };
160
+ }
161
+ function hasInterpolation(value) {
162
+ if (typeof value === "string") {
163
+ return /\$\{([^}]+)\}/.test(value);
164
+ }
165
+ if (Array.isArray(value)) {
166
+ return value.some(hasInterpolation);
167
+ }
168
+ if (value && typeof value === "object") {
169
+ return Object.values(value).some(hasInterpolation);
170
+ }
171
+ return false;
172
+ }
173
+ function hasBuildStanza(compose) {
174
+ if (!compose.services) {
175
+ return false;
176
+ }
177
+ return Object.values(compose.services).some((service) => "build" in service && service.build !== undefined);
178
+ }
179
+ /**
180
+ * Chooses the local route D0 can safely emit. The first dynamic slice is
181
+ * deliberately small: only string/boolean wizard answers interpolated as
182
+ * ordinary Compose values. Secrets/configs remain rejected because the live
183
+ * DSM worker does not load docker-compose.override.yml, and no secret may be
184
+ * placed in the project .env.
185
+ */
186
+ export function classifyComposeRoute(contract, parsed, composeFile = "compose.yaml") {
187
+ const eligibility = resourceWorkerEligibility(contract, parsed.compose);
188
+ const rejectErrors = contract.adminport !== undefined
189
+ ? collectRouteRejectErrors(contract, parsed, composeFile)
190
+ : [];
191
+ if (rejectErrors.length > 0) {
192
+ return { route: "reject", eligibility, errors: rejectErrors };
193
+ }
194
+ const dynamicError = contract.adminport !== undefined
195
+ ? collectDynamicRouteError(contract, parsed, composeFile)
196
+ : undefined;
197
+ if (dynamicError) {
198
+ return {
199
+ route: "reject",
200
+ eligibility,
201
+ errors: [dynamicError],
202
+ };
203
+ }
204
+ if (eligibility.eligible) {
205
+ return { route: "static-zero-config", eligibility, errors: [] };
206
+ }
207
+ // Keep the route decision in one place. build-spk must not independently
208
+ // reinterpret an ineligible Compose as D1: only a route with a resolved
209
+ // published adminport and at least one declared install parameter has a
210
+ // package-only materializer. Every other buildable fallback is Broker.
211
+ if (eligibility.adminport !== undefined && collectInstallParameters(contract).length > 0) {
212
+ return { route: "dynamic", eligibility, errors: [] };
213
+ }
214
+ return { route: "broker-installer", eligibility, errors: [] };
215
+ }
216
+ export function brokerInstallerRouteWarning(compose, composeFile = "compose.yaml") {
217
+ const path = findDynamicComposePath(compose) ?? "services";
218
+ return makeError("CURATED_BROKER_ROUTE_FORBIDDEN", composeSpanForPath(compose, composeFile, path), "此 Compose 會落到 Broker installer 路線;rootless Broker 不支援 Container Manager,無法作為 Curated production runtime。", "請改成零設定 resource-worker:使用 ./html bind 與預先 build 的 image;將 ${SYNOPKG_PKGVAR} 等平台 bind 改成 named volume,或改用 D1 dynamic route 的 string/boolean install 參數。", "warning");
219
+ }
220
+ function hasImmutableServiceImages(compose) {
221
+ const services = compose.services;
222
+ if (!services || Object.keys(services).length === 0) {
223
+ return false;
224
+ }
225
+ return Object.values(services).every((service) => typeof service.image === "string" && /@sha256:[a-f0-9]{64}$/.test(service.image.trim()));
226
+ }
227
+ function collectRouteRejectErrors(contract, parsed, composeFile) {
228
+ const { compose } = parsed;
229
+ const errors = collectPermanentRouteErrors(contract, parsed, composeFile);
230
+ const installParams = collectInstallParameters(contract);
231
+ const sharedFolderParams = new Set(installParams.filter(({ param }) => param.type === "shared_folder").map(({ name }) => name));
232
+ const homesParams = new Set(installParams.filter(({ param }) => param.type === "homes").map(({ name }) => name));
233
+ const installParamNames = new Set(installParams.map(({ name }) => name));
234
+ errors.push(...validateComposeHostBinds(compose, composeFile, {
235
+ sharedFolderParams,
236
+ homesParams,
237
+ installParamNames,
238
+ }));
239
+ return dedupeValidationErrors(errors);
240
+ }
241
+ function collectPermanentRouteErrors(contract, parsed, composeFile) {
242
+ if (contract.adminport === undefined) {
243
+ return [];
244
+ }
245
+ const { compose } = parsed;
246
+ const errors = [];
247
+ if (compose.services) {
248
+ for (const [serviceName, service] of Object.entries(compose.services)) {
249
+ const basePath = `services.${serviceName}`;
250
+ if ("build" in service && service.build !== undefined) {
251
+ const path = `${basePath}.build`;
252
+ errors.push(makeError("COMPOSE_BUILD_FORBIDDEN", composeSpanForPath(compose, composeFile, path), `service「${serviceName}」宣告 build:;D0 route 不支援 install-time build。`, "請移除 build:,改用已存在的 image(最好使用 image@sha256:... 固定 digest),再重新執行 validate。"));
253
+ }
254
+ errors.push(...validateSocketMounts(compose, service, composeFile, `${basePath}.volumes`));
255
+ }
256
+ }
257
+ for (const finding of scanComposePrivilege(compose)) {
258
+ errors.push(makeError(finding.code, composeSpanForPath(compose, composeFile, finding.path), `service「${finding.serviceName}」使用 ${finding.flag};D0 route 永久拒收高權限 capability,即使 contract 已宣告也不會放行。`, "請移除 privileged、host network/PID/IPC、devices 或 cap_add,改用 package-only static-zero-config Compose。"));
259
+ }
260
+ return errors;
261
+ }
262
+ function collectDynamicRouteError(contract, parsed, composeFile) {
263
+ const { compose } = parsed;
264
+ const params = collectInstallParameters(contract);
265
+ const hasUnsupportedComposeFiles = hasComposeSecretsOrConfigs(compose);
266
+ const supportedParamNames = new Set(params
267
+ .filter(({ name, param }) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
268
+ (param.type === "string" || param.type === "boolean"))
269
+ .map(({ name }) => name));
270
+ const unsupportedParam = params.find(({ name, param }) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ||
271
+ (param.type !== "string" && param.type !== "boolean"));
272
+ const brokerOidcInterpolationNames = params.length === 0 && hasBrokerManagedOidc(contract)
273
+ ? BROKER_OIDC_INTERPOLATION_VARS
274
+ : [];
275
+ const supportedInterpolationNames = new Set([
276
+ ...supportedParamNames,
277
+ ...PLATFORM_BIND_VARS,
278
+ ...brokerOidcInterpolationNames,
279
+ ]);
280
+ const interpolations = collectComposeInterpolations(compose);
281
+ const hasComposeInterpolation = hasInterpolation(compose);
282
+ const unsupportedInterpolation = !hasComposeInterpolation
283
+ ? undefined
284
+ : interpolations.size === 0
285
+ ? "<unsupported interpolation syntax>"
286
+ : [...interpolations].find((name) => !supportedInterpolationNames.has(name));
287
+ const hasDynamicInputs = params.length > 0 ||
288
+ hasUnsupportedComposeFiles ||
289
+ unsupportedInterpolation !== undefined;
290
+ if (!hasDynamicInputs) {
291
+ return undefined;
292
+ }
293
+ if (!unsupportedParam && !unsupportedInterpolation && !hasUnsupportedComposeFiles) {
294
+ return undefined;
295
+ }
296
+ const reasons = [];
297
+ if (unsupportedParam) {
298
+ reasons.push("unsupported install parameter type: " + String(unsupportedParam.param.type ?? "unknown"));
299
+ }
300
+ if (unsupportedInterpolation) {
301
+ reasons.push("unsupported interpolation: " + unsupportedInterpolation);
302
+ }
303
+ if (hasUnsupportedComposeFiles) {
304
+ reasons.push("Compose secret/config (DSM override route is not loaded)");
305
+ }
306
+ const reasonText = reasons.join(", ");
307
+ const dynamicPath = findDynamicComposePath(compose);
308
+ const path = dynamicPath ?? (hasUnsupportedComposeFiles ? "secrets" : "services");
309
+ const undeclaredName = unsupportedInterpolation && !unsupportedInterpolation.startsWith("<")
310
+ ? unsupportedInterpolation
311
+ : undefined;
312
+ const interpolationSuggestion = undeclaredName
313
+ ? `請在 synology-app.yaml 的 install 宣告 ${undeclaredName},type 為 string 或 boolean;或從 Compose 移除該引用。`
314
+ : "請只使用 ${NAME} 形式,並在 synology-app.yaml 的 install 宣告對應的 string 或 boolean parameter。";
315
+ return makeError("COMPOSE_DYNAMIC_ROUTE_UNSUPPORTED", composeSpanForPath(compose, composeFile, path), "Compose dynamic route 含有不在 D1/D2 白名單內的輸入(" +
316
+ reasonText +
317
+ ");這條 route 會拒收,不會靜默落回 Broker fallback。", undeclaredName
318
+ ? interpolationSuggestion
319
+ : `${interpolationSuggestion} secret/config 因 DSM worker 不讀 docker-compose.override.yml 而拒收,請移除後重新執行 validate。`);
320
+ }
321
+ function parsedComposeForAnalysis(compose) {
322
+ const source = composeSources.get(compose) ?? "";
323
+ const document = composeDocuments.get(compose) ?? parseDocument(source);
324
+ return { compose, source, document };
325
+ }
326
+ function hasComposeSecretsOrConfigs(compose) {
327
+ return Boolean((compose.secrets && Object.keys(compose.secrets).length > 0) ||
328
+ (compose.configs && typeof compose.configs === "object" && Object.keys(compose.configs).length > 0));
329
+ }
330
+ function findDynamicComposePath(compose) {
331
+ const path = findInterpolationPath(compose, []);
332
+ if (!path || path.length === 0) {
333
+ return undefined;
334
+ }
335
+ if (path[0] === "services" && path.length >= 3) {
336
+ return path.slice(0, 3).join(".");
337
+ }
338
+ return path.join(".");
339
+ }
340
+ function findInterpolationPath(value, path) {
341
+ if (typeof value === "string" && /\$\{([^}]+)\}/.test(value)) {
342
+ return path;
343
+ }
344
+ if (Array.isArray(value)) {
345
+ for (let index = 0; index < value.length; index++) {
346
+ const found = findInterpolationPath(value[index], [...path, String(index)]);
347
+ if (found) {
348
+ return found;
349
+ }
350
+ }
351
+ return undefined;
352
+ }
353
+ if (value && typeof value === "object") {
354
+ for (const [key, child] of Object.entries(value)) {
355
+ const found = findInterpolationPath(child, [...path, key]);
356
+ if (found) {
357
+ return found;
358
+ }
359
+ }
360
+ }
361
+ return undefined;
362
+ }
363
+ function composeSpanForPath(compose, composeFile, path) {
364
+ const fallback = composeSpan(composeFile, 1, 1, path);
365
+ const document = composeDocuments.get(compose);
366
+ const source = composeSources.get(compose);
367
+ if (!document || source === undefined) {
368
+ return fallback;
369
+ }
370
+ const offset = composeKeyOffset(document.contents, path.split("."));
371
+ if (offset === undefined) {
372
+ return fallback;
373
+ }
374
+ const position = lineColumnFromSource(source, offset);
375
+ return composeSpan(composeFile, position.line, position.column, path);
376
+ }
377
+ function composeKeyOffset(node, path) {
378
+ let current = node;
379
+ for (const segment of path) {
380
+ const items = current && Array.isArray(current.items) ? current.items : [];
381
+ const pair = items.find((item) => {
382
+ const key = item?.key;
383
+ return key && String(key.value) === segment;
384
+ });
385
+ if (!pair) {
386
+ return undefined;
387
+ }
388
+ if (segment === path[path.length - 1]) {
389
+ return pair.key?.range?.[0];
390
+ }
391
+ current = pair.value;
392
+ }
393
+ return undefined;
394
+ }
395
+ function lineColumnFromSource(source, offset) {
396
+ let line = 1;
397
+ let column = 1;
398
+ for (let index = 0; index < offset && index < source.length; index++) {
399
+ if (source[index] === "\n") {
400
+ line++;
401
+ column = 1;
402
+ }
403
+ else {
404
+ column++;
405
+ }
406
+ }
407
+ return { line, column };
408
+ }
409
+ function dedupeValidationErrors(errors) {
410
+ const seen = new Set();
411
+ return errors.filter((error) => {
412
+ const key = validationErrorKey(error);
413
+ if (seen.has(key)) {
414
+ return false;
415
+ }
416
+ seen.add(key);
417
+ return true;
418
+ });
419
+ }
420
+ function validationErrorKey(error) {
421
+ return [error.code, error.span.file, error.span.line, error.span.column, error.span.path].join("\u0000");
422
+ }
80
423
  export function collectComposeInterpolations(compose) {
81
424
  const names = new Set();
82
425
  walkForInterpolation(compose, names);
@@ -84,7 +427,7 @@ export function collectComposeInterpolations(compose) {
84
427
  }
85
428
  function walkForInterpolation(value, names) {
86
429
  if (typeof value === "string") {
87
- for (const match of value.matchAll(/\$\{([A-Z0-9_]+)\}/g)) {
430
+ for (const match of value.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
88
431
  names.add(match[1]);
89
432
  }
90
433
  return;
@@ -103,25 +446,73 @@ function walkForInterpolation(value, names) {
103
446
  }
104
447
  export function validateComposeSecurity(compose, composeFile, options) {
105
448
  const errors = [];
449
+ const contract = composeContracts.get(compose);
450
+ const route = contract
451
+ ? classifyComposeRoute(contract, parsedComposeForAnalysis(compose), composeFile)
452
+ : undefined;
453
+ const routeErrors = route
454
+ ? route.errors.filter((error) => !HOST_BIND_ERROR_CODES.has(error.code))
455
+ : [];
456
+ errors.push(...routeErrors);
457
+ if (route?.route === "broker-installer" && hasImmutableServiceImages(compose)) {
458
+ errors.push(brokerInstallerRouteWarning(compose, composeFile));
459
+ }
460
+ const routeErrorKeys = new Set(routeErrors.map(validationErrorKey));
106
461
  if (!compose.services) {
107
462
  return errors;
108
463
  }
109
464
  const undeclaredSeverity = options.profile === "listing" ? "error" : "warning";
110
465
  for (const finding of undeclaredPrivilegeFindings(compose, options.declaredPrivilege)) {
111
- errors.push(makeError(finding.code, composeSpan(composeFile, 1, 1, finding.path), finding.message, finding.suggestion, undeclaredSeverity));
466
+ const error = makeError(finding.code, composeSpanForPath(compose, composeFile, finding.path), finding.message, finding.suggestion, undeclaredSeverity);
467
+ if (!routeErrorKeys.has(validationErrorKey(error))) {
468
+ errors.push(error);
469
+ }
112
470
  }
113
471
  for (const [serviceName, service] of Object.entries(compose.services)) {
114
472
  const basePath = `services.${serviceName}`;
115
- errors.push(...validateSocketMounts(service, composeFile, basePath));
473
+ for (const error of validateSocketMounts(compose, service, composeFile, basePath)) {
474
+ if (!routeErrorKeys.has(validationErrorKey(error))) {
475
+ errors.push(error);
476
+ }
477
+ }
116
478
  const dependsOn = normalizeDependsOn(service.depends_on);
117
479
  for (const dependency of dependsOn) {
118
480
  if (!compose.services?.[dependency]) {
119
- errors.push(makeError("COMPOSE_SERVICE_NOT_FOUND", composeSpan(composeFile, 1, 1, `${basePath}.depends_on`), `Compose 引用了不存在的 service「${dependency}」。`, "請修正 depends_on,或補上對應的 service 定義。"));
481
+ errors.push(makeError("COMPOSE_SERVICE_NOT_FOUND", composeSpanForPath(compose, composeFile, `${basePath}.depends_on`), `Compose 引用了不存在的 service「${dependency}」。`, "請修正 depends_on,或補上對應的 service 定義。"));
120
482
  }
121
483
  }
122
484
  }
123
485
  return errors;
124
486
  }
487
+ const COMMAND_ARGUMENT_FIELDS = ["command", "entrypoint"];
488
+ const PURE_NUMERIC_STRING = /^[0-9]+$/;
489
+ /**
490
+ * DSM's Docker Project resource worker can lose YAML quotes while acquiring a
491
+ * project. A quoted numeric command argument can therefore become a number;
492
+ * reject that platform-invalid shape before it reaches the NAS.
493
+ */
494
+ export function validateComposeNumericCommandArgs(compose, composeFile) {
495
+ const errors = [];
496
+ if (!compose.services) {
497
+ return errors;
498
+ }
499
+ for (const [serviceName, service] of Object.entries(compose.services)) {
500
+ const basePath = `services.${serviceName}`;
501
+ for (const field of COMMAND_ARGUMENT_FIELDS) {
502
+ const value = service[field];
503
+ if (!Array.isArray(value)) {
504
+ continue;
505
+ }
506
+ const numericStrings = value.filter((item) => typeof item === "string" && PURE_NUMERIC_STRING.test(item));
507
+ if (numericStrings.length === 0) {
508
+ continue;
509
+ }
510
+ const quotedValues = numericStrings.map((item) => `「${item}」`).join("、");
511
+ errors.push(makeError("COMPOSE_NUMERIC_COMMAND_ARG_FORBIDDEN", composeSpanForPath(compose, composeFile, `${basePath}.${field}`), `${field} 陣列含純數字字串 ${quotedValues}。此 Compose 會走 DSM Docker Project resource worker;worker 可能在 YAML round-trip 時移除引號並將它轉成整數,導致 project acquire 失敗。`, `請把整個命令包成單一字串,例如 ${field}: ["sh", "-c", "httpd -f -p 8080 -h /srv"],或與 flag 併寫成 "-p8080"。`));
512
+ }
513
+ }
514
+ return errors;
515
+ }
125
516
  function normalizeDependsOn(value) {
126
517
  if (!value) {
127
518
  return [];
@@ -134,13 +525,13 @@ function normalizeDependsOn(value) {
134
525
  }
135
526
  return [String(value)];
136
527
  }
137
- function validateSocketMounts(service, composeFile, basePath) {
528
+ function validateSocketMounts(compose, service, composeFile, basePath) {
138
529
  const errors = [];
139
530
  const volumes = normalizeVolumes(service.volumes);
140
531
  for (const mount of volumes) {
141
532
  const source = mount.split(":")[0]?.trim() ?? "";
142
533
  if (isForbiddenSocketMount(source)) {
143
- errors.push(makeError("COMPOSE_SOCKET_MOUNT_FORBIDDEN", composeSpan(composeFile, 1, 1, `${basePath}.volumes`), "Managed Profile 不允許掛載 Docker 或 Container Manager socket。", "請移除 docker.sock/container-manager socket 的 volume mount。"));
534
+ errors.push(makeError("COMPOSE_SOCKET_MOUNT_FORBIDDEN", composeSpanForPath(compose, composeFile, `${basePath}.volumes`), "Managed Profile 不允許掛載 Docker 或 Container Manager socket。", "請移除 docker.sock/container-manager socket 的 volume mount。"));
144
535
  }
145
536
  }
146
537
  return errors;
@@ -166,7 +557,31 @@ function isForbiddenSocketMount(source) {
166
557
  const normalized = source.replace(/\/+$/, "");
167
558
  return DOCKER_SOCKET_PATHS.some((path) => normalized === path.replace(/\/+$/, "") || normalized.endsWith("/docker.sock"));
168
559
  }
560
+ /**
561
+ * These are the only implicit interpolation names supplied by the platform.
562
+ * The package-only lifecycle materializer writes both paths; every other
563
+ * ${NAME} must be declared in contract.install as string or boolean.
564
+ */
169
565
  const PLATFORM_BIND_VARS = new Set(["SYNOPKG_PKGVAR", "SYNO_APP_SECRETS_DIR"]);
566
+ /**
567
+ * Broker-managed OIDC compatibility injects these two names at install time.
568
+ * They are allowed only for an explicitly declared oidc_compat legacy route;
569
+ * the D1 package-only materializer does not write them.
570
+ */
571
+ const BROKER_OIDC_INTERPOLATION_VARS = new Set([
572
+ "SYNOPKGLAND_OIDC_ISSUER",
573
+ "SYNOPKGLAND_OIDC_CLIENT_ID",
574
+ ]);
575
+ function hasBrokerManagedOidc(contract) {
576
+ if (!contract.integration || typeof contract.integration !== "object") {
577
+ return false;
578
+ }
579
+ const identity = contract.integration.identity;
580
+ if (!identity || typeof identity !== "object") {
581
+ return false;
582
+ }
583
+ return Boolean(identity.oidc_compat);
584
+ }
170
585
  const INTERPOLATED_SOURCE = /^\$\{([A-Z0-9_]+)\}(.*)$/;
171
586
  const VALID_SUFFIX_SEGMENT = /^[A-Za-z0-9._-]+$/;
172
587
  function collectNamedVolumeNames(compose) {
@@ -192,10 +607,11 @@ export function validateComposeHostBinds(compose, composeFile, context) {
192
607
  return errors;
193
608
  }
194
609
  const namedVolumes = collectNamedVolumeNames(compose);
610
+ const projectRoot = composeProjectRoots.get(compose) ?? dirname(resolve(composeFile));
195
611
  for (const [serviceName, service] of Object.entries(compose.services)) {
196
612
  const spanPath = `services.${serviceName}.volumes`;
197
613
  for (const mount of parseVolumeMounts(service.volumes)) {
198
- const error = classifyVolumeSource(mount, namedVolumes, context, composeFile, spanPath);
614
+ const error = classifyVolumeSource(compose, mount, namedVolumes, context, composeFile, spanPath, projectRoot);
199
615
  if (error) {
200
616
  errors.push(error);
201
617
  }
@@ -251,12 +667,12 @@ function parseShortVolumeSource(entry) {
251
667
  }
252
668
  return trimmed.slice(0, colon).trim();
253
669
  }
254
- function classifyVolumeSource(mount, namedVolumes, context, composeFile, spanPath) {
670
+ function classifyVolumeSource(compose, mount, namedVolumes, context, composeFile, spanPath, projectRoot) {
255
671
  const source = mount.source;
256
672
  if (!source || isForbiddenSocketMount(source)) {
257
673
  return null;
258
674
  }
259
- const span = composeSpan(composeFile, 1, 1, spanPath);
675
+ const span = composeSpanForPath(compose, composeFile, spanPath);
260
676
  if (source.includes("$")) {
261
677
  const match = source.match(INTERPOLATED_SOURCE);
262
678
  const varName = match?.[1];
@@ -264,16 +680,21 @@ function classifyVolumeSource(mount, namedVolumes, context, composeFile, spanPat
264
680
  if (!varName || suffix === undefined || !isValidatedBindSuffix(suffix)) {
265
681
  return invalidBindSuffix(span);
266
682
  }
267
- if (PLATFORM_BIND_VARS.has(varName) || context.sharedFolderParams.has(varName)) {
683
+ if (PLATFORM_BIND_VARS.has(varName) ||
684
+ context.sharedFolderParams.has(varName) ||
685
+ context.homesParams?.has(varName)) {
268
686
  return null;
269
687
  }
270
688
  if (context.installParamNames.has(varName)) {
271
- return makeError("COMPOSE_BIND_SOURCE_FORBIDDEN", span, `參數「${varName}」不能作為 host bind source。`, "請改用 ${SYNOPKG_PKGVAR}、${SYNO_APP_SECRETS_DIR} 或 type 為 shared_folder 的 install 參數。");
689
+ return makeError("COMPOSE_BIND_SOURCE_FORBIDDEN", span, `參數「${varName}」不能作為 host bind source。`, "請改用 ${SYNOPKG_PKGVAR}、${SYNO_APP_SECRETS_DIR}、type 為 shared_folder 的 install 參數,或 type 為 homes 的平台解析槽。");
272
690
  }
273
- return makeError("COMPOSE_BIND_SOURCE_UNDECLARED", span, `volume source 引用了未宣告的參數「${varName}」。`, `請在 synology-app.yaml 的 install 宣告 ${varName},且 type 為 shared_folder。`);
691
+ return makeError("COMPOSE_BIND_SOURCE_UNDECLARED", span, `volume source 引用了未宣告的參數「${varName}」。`, `請在 synology-app.yaml 的 install 宣告 ${varName},且 type 為 shared_folder 或 homes。`);
692
+ }
693
+ if (isProjectRelativePath(source, projectRoot)) {
694
+ return null;
274
695
  }
275
696
  if (isLiteralHostPath(source)) {
276
- return makeError("COMPOSE_HOST_BIND_FORBIDDEN", span, "禁止將 /volume*、絕對路徑或開發者機器路徑寫成 Compose volume source。", "請改用 install 參數 type: shared_folder,並以 ${參數名} 作為 bind source。");
697
+ return makeError("COMPOSE_HOST_BIND_FORBIDDEN", span, "禁止將 /volume*、絕對路徑或開發者機器路徑寫成 Compose volume source。", "請改用 install 參數 type: shared_folder 或 homes,並以 ${參數名} 作為 bind source。");
277
698
  }
278
699
  if (mount.type === "tmpfs") {
279
700
  return null;
@@ -307,6 +728,49 @@ function isValidatedBindSuffix(suffix) {
307
728
  function isLiteralHostPath(source) {
308
729
  return source.startsWith("/") || source.startsWith(".") || source.startsWith("~");
309
730
  }
731
+ function isProjectRelativePath(source, projectRoot) {
732
+ if (!source.startsWith("./")) {
733
+ return false;
734
+ }
735
+ const relativeSource = source.slice(2);
736
+ if (relativeSource.length === 0 ||
737
+ relativeSource.includes("\0") ||
738
+ relativeSource.split("/").some((segment) => segment.length === 0 || segment === "..")) {
739
+ return false;
740
+ }
741
+ const projectPath = resolve(projectRoot);
742
+ const candidatePath = resolve(projectPath, source);
743
+ if (!isStrictDescendant(candidatePath, projectPath)) {
744
+ return false;
745
+ }
746
+ const realProjectPath = existingRealPath(projectPath);
747
+ const realCandidatePath = existingRealPath(candidatePath);
748
+ return (realProjectPath !== undefined &&
749
+ realCandidatePath !== undefined &&
750
+ isStrictDescendant(realCandidatePath, realProjectPath));
751
+ }
752
+ function isStrictDescendant(candidatePath, projectPath) {
753
+ const rel = relative(projectPath, candidatePath);
754
+ return rel.length > 0 && rel !== ".." && !rel.startsWith(`..${sep}`) && !rel.startsWith(sep);
755
+ }
756
+ function existingRealPath(path) {
757
+ let current = path;
758
+ while (!existsSync(current)) {
759
+ const parent = dirname(current);
760
+ if (parent === current) {
761
+ return undefined;
762
+ }
763
+ current = parent;
764
+ }
765
+ try {
766
+ const realCurrent = realpathSync(current);
767
+ const missingSuffix = relative(current, path);
768
+ return resolve(realCurrent, missingSuffix || ".");
769
+ }
770
+ catch {
771
+ return undefined;
772
+ }
773
+ }
310
774
  const NUMERIC_USER = /^(0|[1-9][0-9]*)(:(0|[1-9][0-9]*))?$/;
311
775
  const ROOT_USERS = new Set(["0", "0:0", "root"]);
312
776
  export function validateComposeRunAs(compose, composeFile, context) {
@@ -320,7 +784,7 @@ export function validateComposeRunAs(compose, composeFile, context) {
320
784
  continue;
321
785
  }
322
786
  const user = raw.trim();
323
- const span = composeSpan(composeFile, 1, 1, `services.${serviceName}.user`);
787
+ const span = composeSpanForPath(compose, composeFile, `services.${serviceName}.user`);
324
788
  if (user.includes("${")) {
325
789
  continue;
326
790
  }
@@ -339,7 +803,7 @@ export function validateAdminPortMapping(adminport, compose, composeFile) {
339
803
  const services = published.get(adminport) ?? [];
340
804
  if (services.length !== 1) {
341
805
  return [
342
- makeError("CONTRACT_ADMINPORT_UNMAPPED", composeSpan(composeFile, 1, 1, `adminport:${adminport}`), `adminport ${adminport} 必須對應 Compose 中恰好一個 published TCP port。`, "請在 compose.yaml 發佈與 adminport 相同的 host port,並確認只有一個 service 使用它。"),
806
+ makeError("CONTRACT_ADMINPORT_UNMAPPED", composeSpanForPath(compose, composeFile, `adminport:${adminport}`), `adminport ${adminport} 必須對應 Compose 中恰好一個 published TCP port。`, "請在 compose.yaml 發佈與 adminport 相同的 host port,並確認只有一個 service 使用它。"),
343
807
  ];
344
808
  }
345
809
  return [];
@@ -3,6 +3,7 @@ import type { AppContract, InstallParameter } from "../types.js";
3
3
  export interface ParsedContract {
4
4
  contract: AppContract;
5
5
  doc: Document;
6
+ contractPath?: string;
6
7
  }
7
8
  export declare function loadContract(contractPath: string): ParsedContract;
8
9
  export declare function getScalarPosition(doc: Document, keyPath: string[]): {
@@ -4,7 +4,7 @@ export function loadContract(contractPath) {
4
4
  const source = readFileSync(contractPath, "utf8");
5
5
  const doc = parseDocument(source);
6
6
  const contract = (doc.toJSON() ?? {});
7
- return { contract, doc };
7
+ return { contract, doc, contractPath };
8
8
  }
9
9
  export function getScalarPosition(doc, keyPath) {
10
10
  const node = findNode(doc.contents, keyPath);
@@ -0,0 +1,16 @@
1
+ import type { ComposeDocument, ValidationError } from "../types.js";
2
+ /** Install type for a deliberate, install-time snapshot of every DSM Share. */
3
+ export declare const POSIX_ALL_SHARES_TYPE: "posix_all_shares";
4
+ export declare function isPosixAllSharesType(type: unknown): boolean;
5
+ export declare function collectPosixAllSharesParameters(installParams: ReadonlyArray<{
6
+ name: string;
7
+ param: {
8
+ type?: unknown;
9
+ };
10
+ }>): Set<string>;
11
+ /**
12
+ * Validate the extra semantics of the all-shares marker without changing the
13
+ * shared host-bind analyzer. The latter is intentionally left as the single
14
+ * authority for absolute paths, suffix syntax, and named volumes.
15
+ */
16
+ export declare function validatePosixAllSharesSources(compose: ComposeDocument, composeFile: string, declaredParameters: ReadonlySet<string>): ValidationError[];