@synopackageland/cli 0.3.0 → 0.3.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 (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 +210 -14
  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 +41 -1
  10. package/dist/compose/analyze.js +458 -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 +67 -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 +3 -0
  20. package/dist/deploy/deploy.js +38 -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 +5 -0
  26. package/dist/deploy/spk-version.js +50 -18
  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 +827 -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 +12 -0
  36. package/dist/host/catalog-install.js +83 -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 +56 -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 +4 -0
  45. package/dist/publish/curated-release.js +3 -3
  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 +14 -1
  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,20 +1,60 @@
1
- import type { ComposeDocument, ComposeService, ValidationError } from "../types.js";
1
+ import { type Document } from "yaml";
2
+ import type { AppContract, ComposeDocument, ComposeService, ValidationError } from "../types.js";
2
3
  import { type PrivilegeFlag } from "./privilege.js";
3
4
  export interface ParsedCompose {
4
5
  compose: ComposeDocument;
5
6
  source: string;
7
+ document: Document;
6
8
  }
7
9
  export declare function loadCompose(composePath: string): ParsedCompose | null;
8
10
  export declare function listServiceNames(compose: ComposeDocument): string[];
9
11
  export declare function extractPublishedTcpPorts(service: ComposeService): number[];
10
12
  export declare function collectPublishedTcpPorts(compose: ComposeDocument): Map<number, string[]>;
13
+ export type ResourceWorkerIneligibleReason = "HAS_INSTALL_PARAMETERS" | "HAS_COMPOSE_INTERPOLATION" | "HAS_COMPOSE_SECRETS_OR_CONFIGS" | "HAS_BUILD_STANZA" | "HAS_PRIVILEGED_CAPABILITY" | "WEB_APP_MISSING_OR_AMBIGUOUS_ADMINPORT";
14
+ export interface ResourceWorkerEligibility {
15
+ eligible: boolean;
16
+ reasons: ResourceWorkerIneligibleReason[];
17
+ adminport?: number;
18
+ }
19
+ /**
20
+ * Checks if a Compose contract and document are eligible for the zero-config DSM resource-worker path.
21
+ * Zero-config slice rejects:
22
+ * - any install parameter
23
+ * - any ${...} interpolation anywhere in compose document
24
+ * - compose secrets or configs
25
+ * - build: stanzas
26
+ * - privileged / host network / device / root-like capabilities
27
+ * - any App without a unique literal published adminport status probe
28
+ */
29
+ export declare function resourceWorkerEligibility(contract: AppContract, compose: ComposeDocument): ResourceWorkerEligibility;
30
+ export type ComposeRoute = "static-zero-config" | "dynamic" | "reject";
31
+ export interface ComposeRouteClassification {
32
+ route: ComposeRoute;
33
+ eligibility: ResourceWorkerEligibility;
34
+ errors: ValidationError[];
35
+ }
36
+ /**
37
+ * Chooses the local route D0 can safely emit. The first dynamic slice is
38
+ * deliberately small: only string/boolean wizard answers interpolated as
39
+ * ordinary Compose values. Secrets/configs remain rejected because the live
40
+ * DSM worker does not load docker-compose.override.yml, and no secret may be
41
+ * placed in the project .env.
42
+ */
43
+ export declare function classifyComposeRoute(contract: AppContract, parsed: ParsedCompose, composeFile?: string): ComposeRouteClassification;
11
44
  export declare function collectComposeInterpolations(compose: ComposeDocument): Set<string>;
12
45
  export declare function validateComposeSecurity(compose: ComposeDocument, composeFile: string, options: {
13
46
  profile: "personal" | "listing";
14
47
  declaredPrivilege: ReadonlySet<PrivilegeFlag>;
15
48
  }): ValidationError[];
49
+ /**
50
+ * DSM's Docker Project resource worker can lose YAML quotes while acquiring a
51
+ * project. A quoted numeric command argument can therefore become a number;
52
+ * reject that platform-invalid shape before it reaches the NAS.
53
+ */
54
+ export declare function validateComposeNumericCommandArgs(compose: ComposeDocument, composeFile: string): ValidationError[];
16
55
  export interface HostBindContext {
17
56
  sharedFolderParams: ReadonlySet<string>;
57
+ homesParams?: ReadonlySet<string>;
18
58
  installParamNames: ReadonlySet<string>;
19
59
  }
20
60
  export declare function collectVolumeSourceInterpolations(compose: ComposeDocument): Set<string>;
@@ -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,304 @@ 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
+ return { route: "dynamic", eligibility, errors: [] };
208
+ }
209
+ function collectRouteRejectErrors(contract, parsed, composeFile) {
210
+ const { compose } = parsed;
211
+ const errors = collectPermanentRouteErrors(contract, parsed, composeFile);
212
+ const installParams = collectInstallParameters(contract);
213
+ const sharedFolderParams = new Set(installParams.filter(({ param }) => param.type === "shared_folder").map(({ name }) => name));
214
+ const homesParams = new Set(installParams.filter(({ param }) => param.type === "homes").map(({ name }) => name));
215
+ const installParamNames = new Set(installParams.map(({ name }) => name));
216
+ errors.push(...validateComposeHostBinds(compose, composeFile, {
217
+ sharedFolderParams,
218
+ homesParams,
219
+ installParamNames,
220
+ }));
221
+ return dedupeValidationErrors(errors);
222
+ }
223
+ function collectPermanentRouteErrors(contract, parsed, composeFile) {
224
+ if (contract.adminport === undefined) {
225
+ return [];
226
+ }
227
+ const { compose } = parsed;
228
+ const errors = [];
229
+ if (compose.services) {
230
+ for (const [serviceName, service] of Object.entries(compose.services)) {
231
+ const basePath = `services.${serviceName}`;
232
+ if ("build" in service && service.build !== undefined) {
233
+ const path = `${basePath}.build`;
234
+ errors.push(makeError("COMPOSE_BUILD_FORBIDDEN", composeSpanForPath(compose, composeFile, path), `service「${serviceName}」宣告 build:;D0 route 不支援 install-time build。`, "請移除 build:,改用已存在的 image(最好使用 image@sha256:... 固定 digest),再重新執行 validate。"));
235
+ }
236
+ errors.push(...validateSocketMounts(compose, service, composeFile, `${basePath}.volumes`));
237
+ }
238
+ }
239
+ for (const finding of scanComposePrivilege(compose)) {
240
+ 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。"));
241
+ }
242
+ return errors;
243
+ }
244
+ function collectDynamicRouteError(contract, parsed, composeFile) {
245
+ const { compose } = parsed;
246
+ const params = collectInstallParameters(contract);
247
+ const hasUnsupportedComposeFiles = hasComposeSecretsOrConfigs(compose);
248
+ const supportedParamNames = new Set(params
249
+ .filter(({ name, param }) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
250
+ (param.type === "string" || param.type === "boolean"))
251
+ .map(({ name }) => name));
252
+ const unsupportedParam = params.find(({ name, param }) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ||
253
+ (param.type !== "string" && param.type !== "boolean"));
254
+ const brokerOidcInterpolationNames = params.length === 0 && hasBrokerManagedOidc(contract)
255
+ ? BROKER_OIDC_INTERPOLATION_VARS
256
+ : [];
257
+ const supportedInterpolationNames = new Set([
258
+ ...supportedParamNames,
259
+ ...PLATFORM_BIND_VARS,
260
+ ...brokerOidcInterpolationNames,
261
+ ]);
262
+ const interpolations = collectComposeInterpolations(compose);
263
+ const hasComposeInterpolation = hasInterpolation(compose);
264
+ const unsupportedInterpolation = !hasComposeInterpolation
265
+ ? undefined
266
+ : interpolations.size === 0
267
+ ? "<unsupported interpolation syntax>"
268
+ : [...interpolations].find((name) => !supportedInterpolationNames.has(name));
269
+ const hasDynamicInputs = params.length > 0 ||
270
+ hasUnsupportedComposeFiles ||
271
+ unsupportedInterpolation !== undefined;
272
+ if (!hasDynamicInputs) {
273
+ return undefined;
274
+ }
275
+ if (!unsupportedParam && !unsupportedInterpolation && !hasUnsupportedComposeFiles) {
276
+ return undefined;
277
+ }
278
+ const reasons = [];
279
+ if (unsupportedParam) {
280
+ reasons.push("unsupported install parameter type: " + String(unsupportedParam.param.type ?? "unknown"));
281
+ }
282
+ if (unsupportedInterpolation) {
283
+ reasons.push("unsupported interpolation: " + unsupportedInterpolation);
284
+ }
285
+ if (hasUnsupportedComposeFiles) {
286
+ reasons.push("Compose secret/config (DSM override route is not loaded)");
287
+ }
288
+ const reasonText = reasons.join(", ");
289
+ const dynamicPath = findDynamicComposePath(compose);
290
+ const path = dynamicPath ?? (hasUnsupportedComposeFiles ? "secrets" : "services");
291
+ const undeclaredName = unsupportedInterpolation && !unsupportedInterpolation.startsWith("<")
292
+ ? unsupportedInterpolation
293
+ : undefined;
294
+ const interpolationSuggestion = undeclaredName
295
+ ? `請在 synology-app.yaml 的 install 宣告 ${undeclaredName},type 為 string 或 boolean;或從 Compose 移除該引用。`
296
+ : "請只使用 ${NAME} 形式,並在 synology-app.yaml 的 install 宣告對應的 string 或 boolean parameter。";
297
+ return makeError("COMPOSE_DYNAMIC_ROUTE_UNSUPPORTED", composeSpanForPath(compose, composeFile, path), "Compose dynamic route 含有不在 D1/D2 白名單內的輸入(" +
298
+ reasonText +
299
+ ");這條 route 會拒收,不會靜默落回 Broker fallback。", undeclaredName
300
+ ? interpolationSuggestion
301
+ : `${interpolationSuggestion} secret/config 因 DSM worker 不讀 docker-compose.override.yml 而拒收,請移除後重新執行 validate。`);
302
+ }
303
+ function parsedComposeForAnalysis(compose) {
304
+ const source = composeSources.get(compose) ?? "";
305
+ const document = composeDocuments.get(compose) ?? parseDocument(source);
306
+ return { compose, source, document };
307
+ }
308
+ function hasComposeSecretsOrConfigs(compose) {
309
+ return Boolean((compose.secrets && Object.keys(compose.secrets).length > 0) ||
310
+ (compose.configs && typeof compose.configs === "object" && Object.keys(compose.configs).length > 0));
311
+ }
312
+ function findDynamicComposePath(compose) {
313
+ const path = findInterpolationPath(compose, []);
314
+ if (!path || path.length === 0) {
315
+ return undefined;
316
+ }
317
+ if (path[0] === "services" && path.length >= 3) {
318
+ return path.slice(0, 3).join(".");
319
+ }
320
+ return path.join(".");
321
+ }
322
+ function findInterpolationPath(value, path) {
323
+ if (typeof value === "string" && /\$\{([^}]+)\}/.test(value)) {
324
+ return path;
325
+ }
326
+ if (Array.isArray(value)) {
327
+ for (let index = 0; index < value.length; index++) {
328
+ const found = findInterpolationPath(value[index], [...path, String(index)]);
329
+ if (found) {
330
+ return found;
331
+ }
332
+ }
333
+ return undefined;
334
+ }
335
+ if (value && typeof value === "object") {
336
+ for (const [key, child] of Object.entries(value)) {
337
+ const found = findInterpolationPath(child, [...path, key]);
338
+ if (found) {
339
+ return found;
340
+ }
341
+ }
342
+ }
343
+ return undefined;
344
+ }
345
+ function composeSpanForPath(compose, composeFile, path) {
346
+ const fallback = composeSpan(composeFile, 1, 1, path);
347
+ const document = composeDocuments.get(compose);
348
+ const source = composeSources.get(compose);
349
+ if (!document || source === undefined) {
350
+ return fallback;
351
+ }
352
+ const offset = composeKeyOffset(document.contents, path.split("."));
353
+ if (offset === undefined) {
354
+ return fallback;
355
+ }
356
+ const position = lineColumnFromSource(source, offset);
357
+ return composeSpan(composeFile, position.line, position.column, path);
358
+ }
359
+ function composeKeyOffset(node, path) {
360
+ let current = node;
361
+ for (const segment of path) {
362
+ const items = current && Array.isArray(current.items) ? current.items : [];
363
+ const pair = items.find((item) => {
364
+ const key = item?.key;
365
+ return key && String(key.value) === segment;
366
+ });
367
+ if (!pair) {
368
+ return undefined;
369
+ }
370
+ if (segment === path[path.length - 1]) {
371
+ return pair.key?.range?.[0];
372
+ }
373
+ current = pair.value;
374
+ }
375
+ return undefined;
376
+ }
377
+ function lineColumnFromSource(source, offset) {
378
+ let line = 1;
379
+ let column = 1;
380
+ for (let index = 0; index < offset && index < source.length; index++) {
381
+ if (source[index] === "\n") {
382
+ line++;
383
+ column = 1;
384
+ }
385
+ else {
386
+ column++;
387
+ }
388
+ }
389
+ return { line, column };
390
+ }
391
+ function dedupeValidationErrors(errors) {
392
+ const seen = new Set();
393
+ return errors.filter((error) => {
394
+ const key = validationErrorKey(error);
395
+ if (seen.has(key)) {
396
+ return false;
397
+ }
398
+ seen.add(key);
399
+ return true;
400
+ });
401
+ }
402
+ function validationErrorKey(error) {
403
+ return [error.code, error.span.file, error.span.line, error.span.column, error.span.path].join("\u0000");
404
+ }
80
405
  export function collectComposeInterpolations(compose) {
81
406
  const names = new Set();
82
407
  walkForInterpolation(compose, names);
@@ -84,7 +409,7 @@ export function collectComposeInterpolations(compose) {
84
409
  }
85
410
  function walkForInterpolation(value, names) {
86
411
  if (typeof value === "string") {
87
- for (const match of value.matchAll(/\$\{([A-Z0-9_]+)\}/g)) {
412
+ for (const match of value.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
88
413
  names.add(match[1]);
89
414
  }
90
415
  return;
@@ -103,25 +428,67 @@ function walkForInterpolation(value, names) {
103
428
  }
104
429
  export function validateComposeSecurity(compose, composeFile, options) {
105
430
  const errors = [];
431
+ const contract = composeContracts.get(compose);
432
+ const routeErrors = contract
433
+ ? classifyComposeRoute(contract, parsedComposeForAnalysis(compose), composeFile).errors.filter((error) => !HOST_BIND_ERROR_CODES.has(error.code))
434
+ : [];
435
+ errors.push(...routeErrors);
436
+ const routeErrorKeys = new Set(routeErrors.map(validationErrorKey));
106
437
  if (!compose.services) {
107
438
  return errors;
108
439
  }
109
440
  const undeclaredSeverity = options.profile === "listing" ? "error" : "warning";
110
441
  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));
442
+ const error = makeError(finding.code, composeSpanForPath(compose, composeFile, finding.path), finding.message, finding.suggestion, undeclaredSeverity);
443
+ if (!routeErrorKeys.has(validationErrorKey(error))) {
444
+ errors.push(error);
445
+ }
112
446
  }
113
447
  for (const [serviceName, service] of Object.entries(compose.services)) {
114
448
  const basePath = `services.${serviceName}`;
115
- errors.push(...validateSocketMounts(service, composeFile, basePath));
449
+ for (const error of validateSocketMounts(compose, service, composeFile, basePath)) {
450
+ if (!routeErrorKeys.has(validationErrorKey(error))) {
451
+ errors.push(error);
452
+ }
453
+ }
116
454
  const dependsOn = normalizeDependsOn(service.depends_on);
117
455
  for (const dependency of dependsOn) {
118
456
  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 定義。"));
457
+ errors.push(makeError("COMPOSE_SERVICE_NOT_FOUND", composeSpanForPath(compose, composeFile, `${basePath}.depends_on`), `Compose 引用了不存在的 service「${dependency}」。`, "請修正 depends_on,或補上對應的 service 定義。"));
120
458
  }
121
459
  }
122
460
  }
123
461
  return errors;
124
462
  }
463
+ const COMMAND_ARGUMENT_FIELDS = ["command", "entrypoint"];
464
+ const PURE_NUMERIC_STRING = /^[0-9]+$/;
465
+ /**
466
+ * DSM's Docker Project resource worker can lose YAML quotes while acquiring a
467
+ * project. A quoted numeric command argument can therefore become a number;
468
+ * reject that platform-invalid shape before it reaches the NAS.
469
+ */
470
+ export function validateComposeNumericCommandArgs(compose, composeFile) {
471
+ const errors = [];
472
+ if (!compose.services) {
473
+ return errors;
474
+ }
475
+ for (const [serviceName, service] of Object.entries(compose.services)) {
476
+ const basePath = `services.${serviceName}`;
477
+ for (const field of COMMAND_ARGUMENT_FIELDS) {
478
+ const value = service[field];
479
+ if (!Array.isArray(value)) {
480
+ continue;
481
+ }
482
+ const numericStrings = value.filter((item) => typeof item === "string" && PURE_NUMERIC_STRING.test(item));
483
+ if (numericStrings.length === 0) {
484
+ continue;
485
+ }
486
+ const quotedValues = numericStrings.map((item) => `「${item}」`).join("、");
487
+ 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"。`));
488
+ }
489
+ }
490
+ return errors;
491
+ }
125
492
  function normalizeDependsOn(value) {
126
493
  if (!value) {
127
494
  return [];
@@ -134,13 +501,13 @@ function normalizeDependsOn(value) {
134
501
  }
135
502
  return [String(value)];
136
503
  }
137
- function validateSocketMounts(service, composeFile, basePath) {
504
+ function validateSocketMounts(compose, service, composeFile, basePath) {
138
505
  const errors = [];
139
506
  const volumes = normalizeVolumes(service.volumes);
140
507
  for (const mount of volumes) {
141
508
  const source = mount.split(":")[0]?.trim() ?? "";
142
509
  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。"));
510
+ 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
511
  }
145
512
  }
146
513
  return errors;
@@ -166,7 +533,31 @@ function isForbiddenSocketMount(source) {
166
533
  const normalized = source.replace(/\/+$/, "");
167
534
  return DOCKER_SOCKET_PATHS.some((path) => normalized === path.replace(/\/+$/, "") || normalized.endsWith("/docker.sock"));
168
535
  }
536
+ /**
537
+ * These are the only implicit interpolation names supplied by the platform.
538
+ * The package-only lifecycle materializer writes both paths; every other
539
+ * ${NAME} must be declared in contract.install as string or boolean.
540
+ */
169
541
  const PLATFORM_BIND_VARS = new Set(["SYNOPKG_PKGVAR", "SYNO_APP_SECRETS_DIR"]);
542
+ /**
543
+ * Broker-managed OIDC compatibility injects these two names at install time.
544
+ * They are allowed only for an explicitly declared oidc_compat legacy route;
545
+ * the D1 package-only materializer does not write them.
546
+ */
547
+ const BROKER_OIDC_INTERPOLATION_VARS = new Set([
548
+ "SYNOPKGLAND_OIDC_ISSUER",
549
+ "SYNOPKGLAND_OIDC_CLIENT_ID",
550
+ ]);
551
+ function hasBrokerManagedOidc(contract) {
552
+ if (!contract.integration || typeof contract.integration !== "object") {
553
+ return false;
554
+ }
555
+ const identity = contract.integration.identity;
556
+ if (!identity || typeof identity !== "object") {
557
+ return false;
558
+ }
559
+ return Boolean(identity.oidc_compat);
560
+ }
170
561
  const INTERPOLATED_SOURCE = /^\$\{([A-Z0-9_]+)\}(.*)$/;
171
562
  const VALID_SUFFIX_SEGMENT = /^[A-Za-z0-9._-]+$/;
172
563
  function collectNamedVolumeNames(compose) {
@@ -192,10 +583,11 @@ export function validateComposeHostBinds(compose, composeFile, context) {
192
583
  return errors;
193
584
  }
194
585
  const namedVolumes = collectNamedVolumeNames(compose);
586
+ const projectRoot = composeProjectRoots.get(compose) ?? dirname(resolve(composeFile));
195
587
  for (const [serviceName, service] of Object.entries(compose.services)) {
196
588
  const spanPath = `services.${serviceName}.volumes`;
197
589
  for (const mount of parseVolumeMounts(service.volumes)) {
198
- const error = classifyVolumeSource(mount, namedVolumes, context, composeFile, spanPath);
590
+ const error = classifyVolumeSource(compose, mount, namedVolumes, context, composeFile, spanPath, projectRoot);
199
591
  if (error) {
200
592
  errors.push(error);
201
593
  }
@@ -251,12 +643,12 @@ function parseShortVolumeSource(entry) {
251
643
  }
252
644
  return trimmed.slice(0, colon).trim();
253
645
  }
254
- function classifyVolumeSource(mount, namedVolumes, context, composeFile, spanPath) {
646
+ function classifyVolumeSource(compose, mount, namedVolumes, context, composeFile, spanPath, projectRoot) {
255
647
  const source = mount.source;
256
648
  if (!source || isForbiddenSocketMount(source)) {
257
649
  return null;
258
650
  }
259
- const span = composeSpan(composeFile, 1, 1, spanPath);
651
+ const span = composeSpanForPath(compose, composeFile, spanPath);
260
652
  if (source.includes("$")) {
261
653
  const match = source.match(INTERPOLATED_SOURCE);
262
654
  const varName = match?.[1];
@@ -264,16 +656,21 @@ function classifyVolumeSource(mount, namedVolumes, context, composeFile, spanPat
264
656
  if (!varName || suffix === undefined || !isValidatedBindSuffix(suffix)) {
265
657
  return invalidBindSuffix(span);
266
658
  }
267
- if (PLATFORM_BIND_VARS.has(varName) || context.sharedFolderParams.has(varName)) {
659
+ if (PLATFORM_BIND_VARS.has(varName) ||
660
+ context.sharedFolderParams.has(varName) ||
661
+ context.homesParams?.has(varName)) {
268
662
  return null;
269
663
  }
270
664
  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 參數。");
665
+ return makeError("COMPOSE_BIND_SOURCE_FORBIDDEN", span, `參數「${varName}」不能作為 host bind source。`, "請改用 ${SYNOPKG_PKGVAR}、${SYNO_APP_SECRETS_DIR}、type 為 shared_folder 的 install 參數,或 type 為 homes 的平台解析槽。");
272
666
  }
273
- return makeError("COMPOSE_BIND_SOURCE_UNDECLARED", span, `volume source 引用了未宣告的參數「${varName}」。`, `請在 synology-app.yaml 的 install 宣告 ${varName},且 type 為 shared_folder。`);
667
+ return makeError("COMPOSE_BIND_SOURCE_UNDECLARED", span, `volume source 引用了未宣告的參數「${varName}」。`, `請在 synology-app.yaml 的 install 宣告 ${varName},且 type 為 shared_folder 或 homes。`);
668
+ }
669
+ if (isProjectRelativePath(source, projectRoot)) {
670
+ return null;
274
671
  }
275
672
  if (isLiteralHostPath(source)) {
276
- return makeError("COMPOSE_HOST_BIND_FORBIDDEN", span, "禁止將 /volume*、絕對路徑或開發者機器路徑寫成 Compose volume source。", "請改用 install 參數 type: shared_folder,並以 ${參數名} 作為 bind source。");
673
+ return makeError("COMPOSE_HOST_BIND_FORBIDDEN", span, "禁止將 /volume*、絕對路徑或開發者機器路徑寫成 Compose volume source。", "請改用 install 參數 type: shared_folder 或 homes,並以 ${參數名} 作為 bind source。");
277
674
  }
278
675
  if (mount.type === "tmpfs") {
279
676
  return null;
@@ -307,6 +704,49 @@ function isValidatedBindSuffix(suffix) {
307
704
  function isLiteralHostPath(source) {
308
705
  return source.startsWith("/") || source.startsWith(".") || source.startsWith("~");
309
706
  }
707
+ function isProjectRelativePath(source, projectRoot) {
708
+ if (!source.startsWith("./")) {
709
+ return false;
710
+ }
711
+ const relativeSource = source.slice(2);
712
+ if (relativeSource.length === 0 ||
713
+ relativeSource.includes("\0") ||
714
+ relativeSource.split("/").some((segment) => segment.length === 0 || segment === "..")) {
715
+ return false;
716
+ }
717
+ const projectPath = resolve(projectRoot);
718
+ const candidatePath = resolve(projectPath, source);
719
+ if (!isStrictDescendant(candidatePath, projectPath)) {
720
+ return false;
721
+ }
722
+ const realProjectPath = existingRealPath(projectPath);
723
+ const realCandidatePath = existingRealPath(candidatePath);
724
+ return (realProjectPath !== undefined &&
725
+ realCandidatePath !== undefined &&
726
+ isStrictDescendant(realCandidatePath, realProjectPath));
727
+ }
728
+ function isStrictDescendant(candidatePath, projectPath) {
729
+ const rel = relative(projectPath, candidatePath);
730
+ return rel.length > 0 && rel !== ".." && !rel.startsWith(`..${sep}`) && !rel.startsWith(sep);
731
+ }
732
+ function existingRealPath(path) {
733
+ let current = path;
734
+ while (!existsSync(current)) {
735
+ const parent = dirname(current);
736
+ if (parent === current) {
737
+ return undefined;
738
+ }
739
+ current = parent;
740
+ }
741
+ try {
742
+ const realCurrent = realpathSync(current);
743
+ const missingSuffix = relative(current, path);
744
+ return resolve(realCurrent, missingSuffix || ".");
745
+ }
746
+ catch {
747
+ return undefined;
748
+ }
749
+ }
310
750
  const NUMERIC_USER = /^(0|[1-9][0-9]*)(:(0|[1-9][0-9]*))?$/;
311
751
  const ROOT_USERS = new Set(["0", "0:0", "root"]);
312
752
  export function validateComposeRunAs(compose, composeFile, context) {
@@ -320,7 +760,7 @@ export function validateComposeRunAs(compose, composeFile, context) {
320
760
  continue;
321
761
  }
322
762
  const user = raw.trim();
323
- const span = composeSpan(composeFile, 1, 1, `services.${serviceName}.user`);
763
+ const span = composeSpanForPath(compose, composeFile, `services.${serviceName}.user`);
324
764
  if (user.includes("${")) {
325
765
  continue;
326
766
  }
@@ -339,7 +779,7 @@ export function validateAdminPortMapping(adminport, compose, composeFile) {
339
779
  const services = published.get(adminport) ?? [];
340
780
  if (services.length !== 1) {
341
781
  return [
342
- makeError("CONTRACT_ADMINPORT_UNMAPPED", composeSpan(composeFile, 1, 1, `adminport:${adminport}`), `adminport ${adminport} 必須對應 Compose 中恰好一個 published TCP port。`, "請在 compose.yaml 發佈與 adminport 相同的 host port,並確認只有一個 service 使用它。"),
782
+ makeError("CONTRACT_ADMINPORT_UNMAPPED", composeSpanForPath(compose, composeFile, `adminport:${adminport}`), `adminport ${adminport} 必須對應 Compose 中恰好一個 published TCP port。`, "請在 compose.yaml 發佈與 adminport 相同的 host port,並確認只有一個 service 使用它。"),
343
783
  ];
344
784
  }
345
785
  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[]): {