@synopackageland/cli 0.14.1 → 0.15.1

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 (121) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +33 -18
  3. package/dist/build-spk/generator.d.ts +3 -2
  4. package/dist/build-spk/generator.js +6 -846
  5. package/dist/build-spk/resource-worker.d.ts +2 -14
  6. package/dist/build-spk/resource-worker.js +3 -166
  7. package/dist/cli.js +208 -56
  8. package/dist/compose/analyze.d.ts +3 -61
  9. package/dist/compose/analyze.js +35 -776
  10. package/dist/compose/env.d.ts +5 -3
  11. package/dist/compose/env.js +17 -25
  12. package/dist/compose/runner.js +23 -6
  13. package/dist/contract/unsupported-types.js +1 -1
  14. package/dist/contract/validate.js +2 -2
  15. package/dist/contract/web-launch.d.ts +1 -12
  16. package/dist/contract/web-launch.js +10 -74
  17. package/dist/ctl/cli.d.ts +36 -0
  18. package/dist/ctl/cli.js +218 -0
  19. package/dist/ctl/descriptor.d.ts +81 -0
  20. package/dist/ctl/descriptor.js +93 -0
  21. package/dist/ctl/dsm-auth.d.ts +10 -0
  22. package/dist/ctl/dsm-auth.js +30 -0
  23. package/dist/ctl/errors.d.ts +17 -0
  24. package/dist/ctl/errors.js +32 -0
  25. package/dist/ctl/flows.d.ts +11 -0
  26. package/dist/ctl/flows.js +52 -0
  27. package/dist/ctl/input.d.ts +20 -0
  28. package/dist/ctl/input.js +128 -0
  29. package/dist/ctl/json.d.ts +2 -0
  30. package/dist/ctl/json.js +38 -0
  31. package/dist/ctl/redaction.d.ts +15 -0
  32. package/dist/ctl/redaction.js +198 -0
  33. package/dist/ctl/service.d.ts +38 -0
  34. package/dist/ctl/service.js +156 -0
  35. package/dist/ctl/store.d.ts +51 -0
  36. package/dist/ctl/store.js +93 -0
  37. package/dist/ctl/transfers.d.ts +14 -0
  38. package/dist/ctl/transfers.js +74 -0
  39. package/dist/ctl/transport.d.ts +11 -0
  40. package/dist/ctl/transport.js +79 -0
  41. package/dist/deploy/deploy.d.ts +4 -3
  42. package/dist/deploy/deploy.js +5 -210
  43. package/dist/deploy/plan.js +26 -7
  44. package/dist/dev.js +5 -4
  45. package/dist/e2e/dsm.d.ts +2 -2
  46. package/dist/e2e/dsm.js +10 -6
  47. package/dist/error-registry.js +1020 -834
  48. package/dist/host/share-permission-lookup.d.ts +1 -1
  49. package/dist/host/share-permission.d.ts +1 -1
  50. package/dist/host/share-permission.js +1 -1
  51. package/dist/info.js +120 -28
  52. package/dist/init.js +188 -64
  53. package/dist/inspect.js +21 -6
  54. package/dist/runtime/executor-digests.json +5 -5
  55. package/dist/skill.js +51 -9
  56. package/dist/standalone/api-description-inspect.d.ts +4 -0
  57. package/dist/standalone/api-description-inspect.js +12 -0
  58. package/dist/standalone/api-description.d.ts +7 -0
  59. package/dist/standalone/api-description.js +73 -0
  60. package/dist/standalone/build.d.ts +3 -0
  61. package/dist/standalone/build.js +140 -33
  62. package/dist/standalone/compose-errors.d.ts +3 -0
  63. package/dist/standalone/compose-errors.js +243 -0
  64. package/dist/standalone/compose.d.ts +97 -10
  65. package/dist/standalone/compose.js +306 -75
  66. package/dist/standalone/daemon.js +111 -29
  67. package/dist/standalone/dsm-window.js +39 -8
  68. package/dist/standalone/identity-gateway.d.ts +9 -2
  69. package/dist/standalone/identity-gateway.js +282 -66
  70. package/dist/standalone/inspect-spk.d.ts +12 -0
  71. package/dist/standalone/inspect-spk.js +666 -136
  72. package/dist/standalone/lifecycle.js +124 -42
  73. package/dist/standalone/manifest.d.ts +8 -1
  74. package/dist/standalone/manifest.js +303 -86
  75. package/dist/standalone/resource.js +6 -2
  76. package/dist/standalone/spk.d.ts +4 -1
  77. package/dist/standalone/spk.js +138 -39
  78. package/dist/template-paths.d.ts +0 -2
  79. package/dist/template-paths.js +0 -5
  80. package/dist/test-command.d.ts +1 -1
  81. package/dist/test-command.js +1 -1
  82. package/dist/validate.d.ts +3 -3
  83. package/dist/validate.js +32 -140
  84. package/package.json +5 -3
  85. package/schema/synopkgland-api-v1.schema.json +423 -0
  86. package/skills/SKILL.md +4 -2
  87. package/skills/reference/compose.md +349 -0
  88. package/skills/reference/create.md +9 -18
  89. package/skills/reference/ctl.md +41 -0
  90. package/skills/reference/deploy.md +1 -1
  91. package/skills/reference/files.md +5 -1
  92. package/skills/reference/identity-gateway.md +8 -144
  93. package/skills/reference/wrap.md +10 -39
  94. package/assets/go/daemon/fixture_linux_test.go +0 -15
  95. package/assets/go/daemon/fixture_other_test.go +0 -7
  96. package/assets/go/daemon/go.mod +0 -5
  97. package/assets/go/daemon/main.go +0 -636
  98. package/assets/go/daemon/main_test.go +0 -315
  99. package/assets/go/daemon/network.go +0 -107
  100. package/assets/go/daemon/network_test.go +0 -86
  101. package/assets/go/daemon/process_linux.go +0 -55
  102. package/assets/go/daemon/process_linux_test.go +0 -161
  103. package/assets/go/daemon/process_other.go +0 -14
  104. package/assets/go/daemon/testdata/child.go +0 -44
  105. package/assets/go/gateway/auth.go +0 -101
  106. package/assets/go/gateway/auth_test.go +0 -216
  107. package/assets/go/gateway/cgi.go +0 -133
  108. package/assets/go/gateway/cgi_test.go +0 -156
  109. package/assets/go/gateway/csrf.go +0 -73
  110. package/assets/go/gateway/csrf_test.go +0 -155
  111. package/assets/go/gateway/directory.go +0 -108
  112. package/assets/go/gateway/directory_test.go +0 -202
  113. package/assets/go/gateway/gateway.go +0 -412
  114. package/assets/go/gateway/gateway_test.go +0 -676
  115. package/assets/go/gateway/go.mod +0 -5
  116. package/assets/go/gateway/groups.go +0 -66
  117. package/assets/go/gateway/main.go +0 -45
  118. package/dist/build-spk/gateway.d.ts +0 -31
  119. package/dist/build-spk/gateway.js +0 -60
  120. package/dist/compose/privilege.d.ts +0 -14
  121. package/dist/compose/privilege.js +0 -102
@@ -1,49 +1,13 @@
1
- import { existsSync, readFileSync, realpathSync } from "node:fs";
2
- import { dirname, join, relative, resolve, sep } from "node:path";
1
+ const DIGEST_PINNED_IMAGE = /@sha256:[a-f0-9]{64}$/;
2
+ import { existsSync, readFileSync } from "node:fs";
3
3
  import { parseDocument } from "yaml";
4
- import { composeSpan, makeError } from "../errors.js";
5
- import { collectInstallParameters, loadContract } from "../contract/load.js";
6
- import { undeclaredPrivilegeFindings } from "./privilege.js";
7
- import { scanComposePrivilege } from "./privilege.js";
8
- const DOCKER_SOCKET_PATHS = [
9
- "/var/run/docker.sock",
10
- "/var/run/docker.sock/",
11
- "/var/run/containers/container-manager.sock",
12
- "/var/run/containers/container-manager.sock/",
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
- ]);
25
- export function loadCompose(composePath) {
26
- if (!existsSync(composePath)) {
4
+ /** Raw structural utilities for tooling; SDK compose owns all runtime policy. */
5
+ export function loadCompose(path) {
6
+ if (!existsSync(path))
27
7
  return null;
28
- }
29
- const source = readFileSync(composePath, "utf8");
30
- const doc = parseDocument(source);
31
- const compose = (doc.toJSON() ?? {});
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 };
8
+ const source = readFileSync(path, "utf8");
9
+ const document = parseDocument(source);
10
+ return { compose: document.toJSON() ?? {}, source, document };
47
11
  }
48
12
  export function listServiceNames(compose) {
49
13
  if (!compose.services || typeof compose.services !== "object") {
@@ -90,36 +54,53 @@ function splitPortEntry(entry) {
90
54
  if (hostIp === undefined && parts.length === 3) {
91
55
  hostIp = parts.shift();
92
56
  }
93
- return { ...(hostIp ? { hostIp } : {}), hostPort: parts[0], containerPort: parts[parts.length - 1] };
57
+ return {
58
+ ...(hostIp ? { hostIp } : {}),
59
+ hostPort: parts[0],
60
+ containerPort: parts[parts.length - 1],
61
+ };
94
62
  }
95
63
  if (entry && typeof entry === "object") {
96
64
  const record = entry;
97
- const hostIp = typeof record.host_ip === "string" && record.host_ip ? record.host_ip : undefined;
65
+ const hostIp = typeof record.host_ip === "string" && record.host_ip
66
+ ? record.host_ip
67
+ : undefined;
98
68
  const published = record.published ?? record.target;
99
69
  return {
100
70
  ...(hostIp ? { hostIp } : {}),
101
- ...(typeof published === "number" || typeof published === "string" ? { hostPort: String(published) } : {}),
102
- ...(typeof record.target === "number" ? { containerPort: String(record.target) } : {}),
71
+ ...(typeof published === "number" || typeof published === "string"
72
+ ? { hostPort: String(published) }
73
+ : {}),
74
+ ...(typeof record.target === "number"
75
+ ? { containerPort: String(record.target) }
76
+ : {}),
103
77
  };
104
78
  }
105
79
  return null;
106
80
  }
107
81
  function parsePortEntry(entry) {
108
- if (entry && typeof entry === "object" && typeof entry.published === "string") {
82
+ if (entry &&
83
+ typeof entry === "object" &&
84
+ typeof entry.published === "string") {
109
85
  return null;
110
86
  }
111
87
  const hostPort = Number(splitPortEntry(entry)?.hostPort);
112
88
  return Number.isInteger(hostPort) ? hostPort : null;
113
89
  }
114
90
  function isLoopbackAddress(address) {
115
- return address !== undefined && (address === "::1" || /^127(\.\d{1,3}){3}$/.test(address));
91
+ return (address !== undefined &&
92
+ (address === "::1" || /^127(\.\d{1,3}){3}$/.test(address)));
116
93
  }
117
94
  /** Every way a Compose service is reachable from outside its container network. */
118
95
  export function listPortExposures(compose) {
119
96
  const exposures = [];
120
97
  for (const [service, definition] of Object.entries(compose.services ?? {})) {
121
98
  if (definition.network_mode === "host") {
122
- exposures.push({ service, entry: "network_mode: host", loopbackOnly: false });
99
+ exposures.push({
100
+ service,
101
+ entry: "network_mode: host",
102
+ loopbackOnly: false,
103
+ });
123
104
  }
124
105
  const ports = definition.ports;
125
106
  for (const entry of ports ? (Array.isArray(ports) ? ports : [ports]) : []) {
@@ -129,7 +110,9 @@ export function listPortExposures(compose) {
129
110
  service,
130
111
  entry: typeof entry === "string" ? entry : JSON.stringify(entry),
131
112
  ...(hostPort === null ? {} : { hostPort }),
132
- ...(parsed?.containerPort ? { containerPort: parsed.containerPort } : {}),
113
+ ...(parsed?.containerPort
114
+ ? { containerPort: parsed.containerPort }
115
+ : {}),
133
116
  loopbackOnly: isLoopbackAddress(parsed?.hostIp),
134
117
  });
135
118
  }
@@ -150,83 +133,6 @@ export function collectPublishedTcpPorts(compose) {
150
133
  }
151
134
  return result;
152
135
  }
153
- /**
154
- * Checks if a Compose contract and document are eligible for the zero-config DSM resource-worker path.
155
- * Zero-config slice rejects:
156
- * - any install parameter
157
- * - any ${...} interpolation anywhere in compose document
158
- * - compose secrets or configs
159
- * - build: stanzas
160
- * - privileged / host network / device / root-like capabilities
161
- * - any non-headless App without a unique literal published adminport status probe
162
- */
163
- export function resourceWorkerEligibility(contract, compose) {
164
- const reasons = [];
165
- const headless = contract.adminport === undefined && collectPublishedTcpPorts(compose).size === 0;
166
- if (contract.install && Object.keys(contract.install).length > 0) {
167
- reasons.push("HAS_INSTALL_PARAMETERS");
168
- }
169
- if (hasInterpolation(compose)) {
170
- reasons.push("HAS_COMPOSE_INTERPOLATION");
171
- }
172
- if ((compose.secrets && Object.keys(compose.secrets).length > 0) ||
173
- (compose.configs && typeof compose.configs === "object" && Object.keys(compose.configs).length > 0)) {
174
- reasons.push("HAS_COMPOSE_SECRETS_OR_CONFIGS");
175
- }
176
- if (hasBuildStanza(compose)) {
177
- reasons.push("HAS_BUILD_STANZA");
178
- }
179
- if (scanComposePrivilege(compose).length > 0) {
180
- reasons.push("HAS_PRIVILEGED_CAPABILITY");
181
- }
182
- let resolvedAdminport;
183
- if (contract.adminport === undefined) {
184
- if (!headless) {
185
- reasons.push("WEB_APP_MISSING_OR_AMBIGUOUS_ADMINPORT");
186
- }
187
- }
188
- else {
189
- const rawPort = Number(contract.adminport);
190
- if (!Number.isInteger(rawPort) || rawPort <= 0 || rawPort > 65535) {
191
- reasons.push("WEB_APP_MISSING_OR_AMBIGUOUS_ADMINPORT");
192
- }
193
- else {
194
- const published = collectPublishedTcpPorts(compose);
195
- const services = published.get(rawPort) ?? [];
196
- if (services.length !== 1) {
197
- reasons.push("WEB_APP_MISSING_OR_AMBIGUOUS_ADMINPORT");
198
- }
199
- else {
200
- resolvedAdminport = rawPort;
201
- }
202
- }
203
- }
204
- return {
205
- eligible: reasons.length === 0,
206
- reasons,
207
- headless,
208
- adminport: resolvedAdminport,
209
- };
210
- }
211
- function hasInterpolation(value) {
212
- if (typeof value === "string") {
213
- return /\$\{([^}]+)\}/.test(value);
214
- }
215
- if (Array.isArray(value)) {
216
- return value.some(hasInterpolation);
217
- }
218
- if (value && typeof value === "object") {
219
- return Object.values(value).some(hasInterpolation);
220
- }
221
- return false;
222
- }
223
- function hasBuildStanza(compose) {
224
- if (!compose.services) {
225
- return false;
226
- }
227
- return Object.values(compose.services).some((service) => "build" in service && service.build !== undefined);
228
- }
229
- const DIGEST_PINNED_IMAGE = /@sha256:[0-9a-f]{64}$/i;
230
136
  /** Service images a registry can move: anything without a trailing `@sha256:<64 hex>` digest. */
231
137
  export function collectUnpinnedImages(compose) {
232
138
  const unpinned = [];
@@ -238,650 +144,3 @@ export function collectUnpinnedImages(compose) {
238
144
  }
239
145
  return unpinned;
240
146
  }
241
- const HEADLESS_UNSUPPORTED_REASONS = new Set([
242
- "HAS_COMPOSE_SECRETS_OR_CONFIGS",
243
- "HAS_BUILD_STANZA",
244
- "HAS_PRIVILEGED_CAPABILITY",
245
- ]);
246
- const HEADLESS_PACKAGE_ONLY_PARAMETER_TYPES = new Set([
247
- "string",
248
- "boolean",
249
- "shared_folder",
250
- "dsm_user",
251
- "posix_all_shares",
252
- ]);
253
- /**
254
- * Chooses the local route D0 can safely emit. The first dynamic slice is
255
- * deliberately small: only string/boolean wizard answers interpolated as
256
- * ordinary Compose values. Secrets/configs remain rejected because the live
257
- * DSM worker does not load docker-compose.override.yml, and no secret may be
258
- * placed in the project .env.
259
- */
260
- export function classifyComposeRoute(contract, parsed, composeFile = "compose.yaml") {
261
- const eligibility = resourceWorkerEligibility(contract, parsed.compose);
262
- const hasHeadlessUnsupportedParameter = collectInstallParameters(contract).some(({ param }) => !HEADLESS_PACKAGE_ONLY_PARAMETER_TYPES.has(String(param.type)));
263
- const headlessPackageOnly = eligibility.headless &&
264
- !hasHeadlessUnsupportedParameter &&
265
- !eligibility.reasons.some((reason) => HEADLESS_UNSUPPORTED_REASONS.has(reason));
266
- const localRoute = contract.adminport !== undefined || headlessPackageOnly;
267
- const rejectErrors = localRoute
268
- ? collectRouteRejectErrors(contract, parsed, composeFile)
269
- : [];
270
- if (rejectErrors.length > 0) {
271
- return { route: "reject", eligibility, errors: rejectErrors };
272
- }
273
- const dynamicError = localRoute
274
- ? collectDynamicRouteError(contract, parsed, composeFile)
275
- : undefined;
276
- if (dynamicError) {
277
- return {
278
- route: "reject",
279
- eligibility,
280
- errors: [dynamicError],
281
- };
282
- }
283
- if (eligibility.eligible) {
284
- return { route: "static-zero-config", eligibility, errors: [] };
285
- }
286
- if ((eligibility.adminport !== undefined || headlessPackageOnly) &&
287
- (collectInstallParameters(contract).length > 0 || hasInterpolation(parsed.compose))) {
288
- return { route: "dynamic", eligibility, errors: [] };
289
- }
290
- return { route: "reject", eligibility, errors: [makeError("COMPOSE_ROUTE_UNSUPPORTED", composeSpanForPath(parsed.compose, composeFile, "services"), `DSM resource worker cannot manage this Compose: ${eligibility.reasons.join(", ")}`, "Use prebuilt images, supported install parameters and DSM docker-project resources.")] };
291
- }
292
- function collectRouteRejectErrors(contract, parsed, composeFile) {
293
- const { compose } = parsed;
294
- const errors = collectPermanentRouteErrors(contract, parsed, composeFile);
295
- const installParams = collectInstallParameters(contract);
296
- const sharedFolderParams = new Set(installParams.filter(({ param }) => param.type === "shared_folder").map(({ name }) => name));
297
- const homesParams = new Set(installParams.filter(({ param }) => param.type === "homes").map(({ name }) => name));
298
- const posixAllSharesParams = new Set(installParams
299
- .filter(({ param }) => param.type === "posix_all_shares")
300
- .map(({ name }) => name));
301
- const installParamNames = new Set(installParams.map(({ name }) => name));
302
- errors.push(...validateComposeHostBinds(compose, composeFile, {
303
- sharedFolderParams,
304
- homesParams,
305
- posixAllSharesParams,
306
- installParamNames,
307
- }));
308
- return dedupeValidationErrors(errors);
309
- }
310
- function collectPermanentRouteErrors(contract, parsed, composeFile) {
311
- const headless = contract.adminport === undefined && collectPublishedTcpPorts(parsed.compose).size === 0;
312
- if (contract.adminport === undefined && !headless) {
313
- return [];
314
- }
315
- const { compose } = parsed;
316
- const errors = [];
317
- const dsmUserParams = collectInstallParameters(contract).filter(({ param }) => param.type === "dsm_user");
318
- if (compose.services) {
319
- for (const [serviceName, service] of Object.entries(compose.services)) {
320
- const basePath = `services.${serviceName}`;
321
- if ("build" in service && service.build !== undefined) {
322
- const path = `${basePath}.build`;
323
- errors.push(makeError("COMPOSE_BUILD_FORBIDDEN", composeSpanForPath(compose, composeFile, path), `service "${serviceName}" declares build:; the D0 route does not support install-time build.`, "Remove build: and use an existing image (preferably pinned with image@sha256:...), then run check again."));
324
- }
325
- if (dsmUserParams.length > 0 && typeof service.user === "string" && service.user.trim() !== "") {
326
- const path = `${basePath}.user`;
327
- errors.push(makeError("COMPOSE_USER_CONFLICTS_DSM_USER", composeSpanForPath(compose, composeFile, path), `service "${serviceName}" defines user: "${service.user.trim()}", which conflicts with the dsm_user declared in the contract.`, "Remove the user field from Compose; the generator injects user: \"${NAME_UID}:${NAME_GID}\" and group_add from dsm_user."));
328
- }
329
- errors.push(...validateSocketMounts(compose, service, composeFile, `${basePath}.volumes`));
330
- }
331
- }
332
- for (const finding of scanComposePrivilege(compose)) {
333
- errors.push(makeError(finding.code, composeSpanForPath(compose, composeFile, finding.path), `service "${finding.serviceName}" uses ${finding.flag}; the D0 route permanently rejects high-privilege capabilities, even when the contract declares them.`, "Remove privileged, host network/PID/IPC, devices or cap_add, and use a package-only static-zero-config Compose."));
334
- }
335
- return errors;
336
- }
337
- function collectDynamicRouteError(contract, parsed, composeFile) {
338
- const { compose } = parsed;
339
- const params = collectInstallParameters(contract);
340
- const hasUnsupportedComposeFiles = hasComposeSecretsOrConfigs(compose);
341
- const supportedParamNames = new Set(params
342
- .filter(({ name, param }) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
343
- (param.type === "string" ||
344
- param.type === "boolean" ||
345
- param.type === "shared_folder" ||
346
- param.type === "dsm_user" ||
347
- param.type === "posix_all_shares"))
348
- .map(({ name }) => name));
349
- const dsmUserParam = params.find(({ param }) => param.type === "dsm_user");
350
- const dsmUserDerivedNames = dsmUserParam
351
- ? [
352
- `${dsmUserParam.name}_UID`,
353
- `${dsmUserParam.name}_GID`,
354
- ...Array.from({ length: 8 }, (_, i) => `${dsmUserParam.name}_GROUP_${i + 1}`),
355
- ]
356
- : [];
357
- const unsupportedParam = params.find(({ name, param }) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ||
358
- (param.type !== "string" &&
359
- param.type !== "boolean" &&
360
- param.type !== "shared_folder" &&
361
- param.type !== "dsm_user" &&
362
- param.type !== "posix_all_shares"));
363
- const supportedInterpolationNames = new Set([
364
- ...supportedParamNames,
365
- ...dsmUserDerivedNames,
366
- ...PLATFORM_BIND_VARS,
367
- ]);
368
- const interpolations = collectComposeInterpolations(compose);
369
- const hasComposeInterpolation = hasInterpolation(compose);
370
- const unsupportedInterpolation = !hasComposeInterpolation
371
- ? undefined
372
- : interpolations.size === 0
373
- ? "<unsupported interpolation syntax>"
374
- : [...interpolations].find((name) => !supportedInterpolationNames.has(name));
375
- const hasDynamicInputs = params.length > 0 ||
376
- hasUnsupportedComposeFiles ||
377
- unsupportedInterpolation !== undefined;
378
- if (!hasDynamicInputs) {
379
- return undefined;
380
- }
381
- if (!unsupportedParam && !unsupportedInterpolation && !hasUnsupportedComposeFiles) {
382
- return undefined;
383
- }
384
- const reasons = [];
385
- if (unsupportedParam) {
386
- reasons.push("unsupported install parameter type: " + String(unsupportedParam.param.type ?? "unknown"));
387
- }
388
- if (unsupportedInterpolation) {
389
- reasons.push("unsupported interpolation: " + unsupportedInterpolation);
390
- }
391
- if (hasUnsupportedComposeFiles) {
392
- reasons.push("Compose secret/config (DSM override route is not loaded)");
393
- }
394
- const reasonText = reasons.join(", ");
395
- const dynamicPath = findDynamicComposePath(compose);
396
- const path = dynamicPath ?? (hasUnsupportedComposeFiles ? "secrets" : "services");
397
- const undeclaredName = unsupportedInterpolation && !unsupportedInterpolation.startsWith("<")
398
- ? unsupportedInterpolation
399
- : undefined;
400
- const interpolationSuggestion = undeclaredName
401
- ? `Declare ${undeclaredName} in the synology-app.yaml install with type string or boolean, or remove the reference from Compose.`
402
- : "Use only the ${NAME} form and declare the matching string or boolean parameter in the synology-app.yaml install.";
403
- return makeError("COMPOSE_DYNAMIC_ROUTE_UNSUPPORTED", composeSpanForPath(compose, composeFile, path), "The Compose dynamic route contains an input outside the D1/D2 allowlist (" +
404
- reasonText +
405
- "); this route is rejected and does not silently fall back to another execution mode.", undeclaredName
406
- ? interpolationSuggestion
407
- : `${interpolationSuggestion} secret/config are rejected because the DSM worker does not read docker-compose.override.yml; remove them and run check again.`);
408
- }
409
- function parsedComposeForAnalysis(compose) {
410
- const source = composeSources.get(compose) ?? "";
411
- const document = composeDocuments.get(compose) ?? parseDocument(source);
412
- return { compose, source, document };
413
- }
414
- function hasComposeSecretsOrConfigs(compose) {
415
- return Boolean((compose.secrets && Object.keys(compose.secrets).length > 0) ||
416
- (compose.configs && typeof compose.configs === "object" && Object.keys(compose.configs).length > 0));
417
- }
418
- function findDynamicComposePath(compose) {
419
- const path = findInterpolationPath(compose, []);
420
- if (!path || path.length === 0) {
421
- return undefined;
422
- }
423
- if (path[0] === "services" && path.length >= 3) {
424
- return path.slice(0, 3).join(".");
425
- }
426
- return path.join(".");
427
- }
428
- function findInterpolationPath(value, path) {
429
- if (typeof value === "string" && /\$\{([^}]+)\}/.test(value)) {
430
- return path;
431
- }
432
- if (Array.isArray(value)) {
433
- for (let index = 0; index < value.length; index++) {
434
- const found = findInterpolationPath(value[index], [...path, String(index)]);
435
- if (found) {
436
- return found;
437
- }
438
- }
439
- return undefined;
440
- }
441
- if (value && typeof value === "object") {
442
- for (const [key, child] of Object.entries(value)) {
443
- const found = findInterpolationPath(child, [...path, key]);
444
- if (found) {
445
- return found;
446
- }
447
- }
448
- }
449
- return undefined;
450
- }
451
- function composeSpanForPath(compose, composeFile, path) {
452
- const fallback = composeSpan(composeFile, 1, 1, path);
453
- const document = composeDocuments.get(compose);
454
- const source = composeSources.get(compose);
455
- if (!document || source === undefined) {
456
- return fallback;
457
- }
458
- const offset = composeKeyOffset(document.contents, path.split("."));
459
- if (offset === undefined) {
460
- return fallback;
461
- }
462
- const position = lineColumnFromSource(source, offset);
463
- return composeSpan(composeFile, position.line, position.column, path);
464
- }
465
- function composeKeyOffset(node, path) {
466
- let current = node;
467
- for (const segment of path) {
468
- const items = current && Array.isArray(current.items) ? current.items : [];
469
- const pair = items.find((item) => {
470
- const key = item?.key;
471
- return key && String(key.value) === segment;
472
- });
473
- if (!pair) {
474
- return undefined;
475
- }
476
- if (segment === path[path.length - 1]) {
477
- return pair.key?.range?.[0];
478
- }
479
- current = pair.value;
480
- }
481
- return undefined;
482
- }
483
- function lineColumnFromSource(source, offset) {
484
- let line = 1;
485
- let column = 1;
486
- for (let index = 0; index < offset && index < source.length; index++) {
487
- if (source[index] === "\n") {
488
- line++;
489
- column = 1;
490
- }
491
- else {
492
- column++;
493
- }
494
- }
495
- return { line, column };
496
- }
497
- function dedupeValidationErrors(errors) {
498
- const seen = new Set();
499
- return errors.filter((error) => {
500
- const key = validationErrorKey(error);
501
- if (seen.has(key)) {
502
- return false;
503
- }
504
- seen.add(key);
505
- return true;
506
- });
507
- }
508
- function validationErrorKey(error) {
509
- return [error.code, error.span.file, error.span.line, error.span.column, error.span.path].join("\u0000");
510
- }
511
- export function collectComposeInterpolations(compose) {
512
- const names = new Set();
513
- walkForInterpolation(compose, names);
514
- return names;
515
- }
516
- function walkForInterpolation(value, names) {
517
- if (typeof value === "string") {
518
- for (const match of value.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
519
- names.add(match[1]);
520
- }
521
- return;
522
- }
523
- if (Array.isArray(value)) {
524
- for (const item of value) {
525
- walkForInterpolation(item, names);
526
- }
527
- return;
528
- }
529
- if (value && typeof value === "object") {
530
- for (const child of Object.values(value)) {
531
- walkForInterpolation(child, names);
532
- }
533
- }
534
- }
535
- export function validateComposeSecurity(compose, composeFile, options) {
536
- const errors = [];
537
- const contract = composeContracts.get(compose);
538
- const route = contract
539
- ? classifyComposeRoute(contract, parsedComposeForAnalysis(compose), composeFile)
540
- : undefined;
541
- const routeErrors = route
542
- ? route.errors.filter((error) => !HOST_BIND_ERROR_CODES.has(error.code))
543
- : [];
544
- errors.push(...routeErrors);
545
- const routeErrorKeys = new Set(routeErrors.map(validationErrorKey));
546
- if (!compose.services) {
547
- return errors;
548
- }
549
- const undeclaredSeverity = options.profile === "listing" ? "error" : "warning";
550
- for (const finding of undeclaredPrivilegeFindings(compose, options.declaredPrivilege)) {
551
- const error = makeError(finding.code, composeSpanForPath(compose, composeFile, finding.path), finding.message, finding.suggestion, undeclaredSeverity);
552
- if (!routeErrorKeys.has(validationErrorKey(error))) {
553
- errors.push(error);
554
- }
555
- }
556
- for (const [serviceName, service] of Object.entries(compose.services)) {
557
- const basePath = `services.${serviceName}`;
558
- for (const error of validateSocketMounts(compose, service, composeFile, basePath)) {
559
- if (!routeErrorKeys.has(validationErrorKey(error))) {
560
- errors.push(error);
561
- }
562
- }
563
- const dependsOn = normalizeDependsOn(service.depends_on);
564
- for (const dependency of dependsOn) {
565
- if (!compose.services?.[dependency]) {
566
- errors.push(makeError("COMPOSE_SERVICE_NOT_FOUND", composeSpanForPath(compose, composeFile, `${basePath}.depends_on`), `Compose references a service that does not exist: "${dependency}".`, "Fix depends_on, or add the matching service definition."));
567
- }
568
- }
569
- }
570
- return errors;
571
- }
572
- const COMMAND_ARGUMENT_FIELDS = ["command", "entrypoint"];
573
- const PURE_NUMERIC_STRING = /^[0-9]+$/;
574
- /**
575
- * DSM's Docker Project resource worker can lose YAML quotes while acquiring a
576
- * project. A quoted numeric command argument can therefore become a number;
577
- * reject that platform-invalid shape before it reaches the NAS.
578
- */
579
- export function validateComposeNumericCommandArgs(compose, composeFile) {
580
- const errors = [];
581
- if (!compose.services) {
582
- return errors;
583
- }
584
- for (const [serviceName, service] of Object.entries(compose.services)) {
585
- const basePath = `services.${serviceName}`;
586
- for (const field of COMMAND_ARGUMENT_FIELDS) {
587
- const value = service[field];
588
- if (!Array.isArray(value)) {
589
- continue;
590
- }
591
- const numericStrings = value.filter((item) => typeof item === "string" && PURE_NUMERIC_STRING.test(item));
592
- if (numericStrings.length === 0) {
593
- continue;
594
- }
595
- const quotedValues = numericStrings.map((item) => `"${item}"`).join(", ");
596
- errors.push(makeError("COMPOSE_NUMERIC_COMMAND_ARG_FORBIDDEN", composeSpanForPath(compose, composeFile, `${basePath}.${field}`), `The ${field} array contains the pure numeric string ${quotedValues}. This Compose goes through the DSM Docker Project resource worker; the worker may drop the quotes during a YAML round-trip and turn it into an integer, so project acquire fails.`, `Wrap the whole command in one string, for example ${field}: ["sh", "-c", "httpd -f -p 8080 -h /srv"], or join it with the flag as "-p8080".`));
597
- }
598
- }
599
- return errors;
600
- }
601
- function normalizeDependsOn(value) {
602
- if (!value) {
603
- return [];
604
- }
605
- if (Array.isArray(value)) {
606
- return value.map(String);
607
- }
608
- if (typeof value === "object") {
609
- return Object.keys(value);
610
- }
611
- return [String(value)];
612
- }
613
- function validateSocketMounts(compose, service, composeFile, basePath) {
614
- const errors = [];
615
- const volumes = normalizeVolumes(service.volumes);
616
- for (const mount of volumes) {
617
- const source = mount.split(":")[0]?.trim() ?? "";
618
- if (isForbiddenSocketMount(source)) {
619
- errors.push(makeError("COMPOSE_SOCKET_MOUNT_FORBIDDEN", composeSpanForPath(compose, composeFile, `${basePath}.volumes`), "A Managed Profile does not allow mounting the Docker or Container Manager socket.", "Remove the docker.sock/container-manager socket volume mount."));
620
- }
621
- }
622
- return errors;
623
- }
624
- function normalizeVolumes(volumes) {
625
- if (!volumes) {
626
- return [];
627
- }
628
- const entries = Array.isArray(volumes) ? volumes : [volumes];
629
- return entries
630
- .map((entry) => {
631
- if (typeof entry === "string") {
632
- return entry;
633
- }
634
- if (entry && typeof entry === "object" && "source" in entry) {
635
- return String(entry.source);
636
- }
637
- return "";
638
- })
639
- .filter(Boolean);
640
- }
641
- function isForbiddenSocketMount(source) {
642
- const normalized = source.replace(/\/+$/, "");
643
- return DOCKER_SOCKET_PATHS.some((path) => normalized === path.replace(/\/+$/, "") || normalized.endsWith("/docker.sock"));
644
- }
645
- /**
646
- * These are the only implicit interpolation names supplied by the platform.
647
- * The package-only lifecycle materializer writes both paths; every other
648
- * ${NAME} must be declared in contract.install as string or boolean.
649
- */
650
- const PLATFORM_BIND_VARS = new Set(["SYNOPKG_PKGVAR", "SYNO_APP_SECRETS_DIR"]);
651
- const INTERPOLATED_SOURCE = /^\$\{([A-Z0-9_]+)\}(.*)$/;
652
- const VALID_SUFFIX_SEGMENT = /^[A-Za-z0-9._-]+$/;
653
- function collectNamedVolumeNames(compose) {
654
- const volumes = compose.volumes;
655
- if (!volumes || typeof volumes !== "object" || Array.isArray(volumes)) {
656
- return new Set();
657
- }
658
- return new Set(Object.keys(volumes));
659
- }
660
- export function collectVolumeSourceInterpolations(compose) {
661
- const names = new Set();
662
- for (const mount of listVolumeMounts(compose)) {
663
- const match = mount.source.match(INTERPOLATED_SOURCE);
664
- if (match) {
665
- names.add(match[1]);
666
- }
667
- }
668
- return names;
669
- }
670
- export function validateComposeHostBinds(compose, composeFile, context) {
671
- const errors = [];
672
- if (!compose.services) {
673
- return errors;
674
- }
675
- const namedVolumes = collectNamedVolumeNames(compose);
676
- const projectRoot = composeProjectRoots.get(compose) ?? dirname(resolve(composeFile));
677
- for (const [serviceName, service] of Object.entries(compose.services)) {
678
- const spanPath = `services.${serviceName}.volumes`;
679
- for (const mount of parseVolumeMounts(service.volumes)) {
680
- const error = classifyVolumeSource(compose, mount, namedVolumes, context, composeFile, spanPath, projectRoot);
681
- if (error) {
682
- errors.push(error);
683
- }
684
- }
685
- }
686
- return errors;
687
- }
688
- function listVolumeMounts(compose) {
689
- if (!compose.services) {
690
- return [];
691
- }
692
- const mounts = [];
693
- for (const service of Object.values(compose.services)) {
694
- mounts.push(...parseVolumeMounts(service.volumes));
695
- }
696
- return mounts;
697
- }
698
- function parseVolumeMounts(volumes) {
699
- if (!volumes) {
700
- return [];
701
- }
702
- const entries = Array.isArray(volumes) ? volumes : [volumes];
703
- const mounts = [];
704
- for (const entry of entries) {
705
- if (typeof entry === "string") {
706
- const source = parseShortVolumeSource(entry);
707
- if (source !== null) {
708
- mounts.push({ source });
709
- }
710
- continue;
711
- }
712
- if (entry && typeof entry === "object") {
713
- const record = entry;
714
- if (typeof record.source !== "string" || !record.source.trim()) {
715
- continue;
716
- }
717
- mounts.push({
718
- source: record.source.trim(),
719
- type: typeof record.type === "string" ? record.type : undefined,
720
- });
721
- }
722
- }
723
- return mounts;
724
- }
725
- function parseShortVolumeSource(entry) {
726
- const trimmed = entry.trim();
727
- if (!trimmed) {
728
- return null;
729
- }
730
- const colon = trimmed.indexOf(":");
731
- if (colon <= 0) {
732
- return null;
733
- }
734
- return trimmed.slice(0, colon).trim();
735
- }
736
- function classifyVolumeSource(compose, mount, namedVolumes, context, composeFile, spanPath, projectRoot) {
737
- const source = mount.source;
738
- if (!source || isForbiddenSocketMount(source)) {
739
- return null;
740
- }
741
- const span = composeSpanForPath(compose, composeFile, spanPath);
742
- if (source.includes("$")) {
743
- const match = source.match(INTERPOLATED_SOURCE);
744
- const varName = match?.[1];
745
- const suffix = match?.[2];
746
- if (!varName || suffix === undefined || !isValidatedBindSuffix(suffix)) {
747
- return invalidBindSuffix(span);
748
- }
749
- if (PLATFORM_BIND_VARS.has(varName) ||
750
- context.sharedFolderParams.has(varName) ||
751
- context.homesParams?.has(varName) ||
752
- context.posixAllSharesParams?.has(varName)) {
753
- return null;
754
- }
755
- if (context.installParamNames.has(varName)) {
756
- return makeError("COMPOSE_BIND_SOURCE_FORBIDDEN", span, `The parameter "${varName}" cannot be a host bind source.`, "Use ${SYNOPKG_PKGVAR}, ${SYNO_APP_SECRETS_DIR}, an install parameter with type shared_folder, or the platform-resolved type homes slot.");
757
- }
758
- return makeError("COMPOSE_BIND_SOURCE_UNDECLARED", span, `The volume source references an undeclared parameter "${varName}".`, `Declare ${varName} in the synology-app.yaml install with type shared_folder or homes.`);
759
- }
760
- if (isProjectRelativePath(source, projectRoot)) {
761
- return null;
762
- }
763
- if (isLiteralHostPath(source)) {
764
- return makeError("COMPOSE_HOST_BIND_FORBIDDEN", span, "Writing /volume*, an absolute path or a developer machine path as a Compose volume source is forbidden.", "Use an install parameter with type: shared_folder or homes and take ${PARAM} as the bind source.");
765
- }
766
- if (mount.type === "tmpfs") {
767
- return null;
768
- }
769
- if (mount.type === "bind") {
770
- return makeError("COMPOSE_BIND_SOURCE_FORBIDDEN", span, "Mounting a host source the platform does not allow with type: bind is forbidden.", "Use ${SYNOPKG_PKGVAR}, ${SYNO_APP_SECRETS_DIR} or a ${shared_folder parameter} from the contract.");
771
- }
772
- if (!namedVolumes.has(source)) {
773
- return makeError("COMPOSE_NAMED_VOLUME_UNDECLARED", span, `The short-syntax named volume "${source}" is not declared in the top-level volumes, so it is unclear whether it is a Docker volume or a Share name.`, `Declare ${source} in the top-level volumes, or use \${PARAM} as the shared_folder bind source.`);
774
- }
775
- return null;
776
- }
777
- function invalidBindSuffix(span) {
778
- return makeError("COMPOSE_BIND_SUFFIX_INVALID", span, "The path suffix of the volume source is invalid.", "The suffix can only be / followed by one or more [A-Za-z0-9._-] segments, and must not contain .., ${ or an absolute path.");
779
- }
780
- function isValidatedBindSuffix(suffix) {
781
- if (suffix === "") {
782
- return true;
783
- }
784
- if (suffix.includes("${") || suffix.includes(":")) {
785
- return false;
786
- }
787
- if (!/^(\/[A-Za-z0-9._-]+)+$/.test(suffix)) {
788
- return false;
789
- }
790
- return suffix
791
- .split("/")
792
- .filter(Boolean)
793
- .every((segment) => VALID_SUFFIX_SEGMENT.test(segment) && segment !== "." && segment !== "..");
794
- }
795
- function isLiteralHostPath(source) {
796
- return source.startsWith("/") || source.startsWith(".") || source.startsWith("~");
797
- }
798
- function isProjectRelativePath(source, projectRoot) {
799
- if (!source.startsWith("./")) {
800
- return false;
801
- }
802
- const relativeSource = source.slice(2);
803
- if (relativeSource.length === 0 ||
804
- relativeSource.includes("\0") ||
805
- relativeSource.split("/").some((segment) => segment.length === 0 || segment === "..")) {
806
- return false;
807
- }
808
- const projectPath = resolve(projectRoot);
809
- const candidatePath = resolve(projectPath, source);
810
- if (!isStrictDescendant(candidatePath, projectPath)) {
811
- return false;
812
- }
813
- const realProjectPath = existingRealPath(projectPath);
814
- const realCandidatePath = existingRealPath(candidatePath);
815
- return (realProjectPath !== undefined &&
816
- realCandidatePath !== undefined &&
817
- isStrictDescendant(realCandidatePath, realProjectPath));
818
- }
819
- function isStrictDescendant(candidatePath, projectPath) {
820
- const rel = relative(projectPath, candidatePath);
821
- return rel.length > 0 && rel !== ".." && !rel.startsWith(`..${sep}`) && !rel.startsWith(sep);
822
- }
823
- function existingRealPath(path) {
824
- let current = path;
825
- while (!existsSync(current)) {
826
- const parent = dirname(current);
827
- if (parent === current) {
828
- return undefined;
829
- }
830
- current = parent;
831
- }
832
- try {
833
- const realCurrent = realpathSync(current);
834
- const missingSuffix = relative(current, path);
835
- return resolve(realCurrent, missingSuffix || ".");
836
- }
837
- catch {
838
- return undefined;
839
- }
840
- }
841
- const NUMERIC_USER = /^(0|[1-9][0-9]*)(:(0|[1-9][0-9]*))?$/;
842
- const ROOT_USERS = new Set(["0", "0:0", "root"]);
843
- export function validateComposeRunAs(compose, composeFile, context) {
844
- const errors = [];
845
- if (!compose.services) {
846
- return errors;
847
- }
848
- for (const [serviceName, service] of Object.entries(compose.services)) {
849
- const raw = service.user;
850
- if (typeof raw !== "string" || raw.trim() === "") {
851
- continue;
852
- }
853
- const user = raw.trim();
854
- const span = composeSpanForPath(compose, composeFile, `services.${serviceName}.user`);
855
- if (context.hasDsmUser) {
856
- errors.push(makeError("COMPOSE_USER_CONFLICTS_DSM_USER", span, `service "${serviceName}" defines user: "${user}", which conflicts with the dsm_user declared in the contract.`, "Remove the user field from Compose; the generator injects user: \"${NAME_UID}:${NAME_GID}\" and group_add from dsm_user."));
857
- continue;
858
- }
859
- if (user.includes("${")) {
860
- continue;
861
- }
862
- if (context.hasSharedFolder && ROOT_USERS.has(user)) {
863
- errors.push(makeError("COMPOSE_USER_ROOT_FORBIDDEN", span, "With a Shared Folder, a container must not write to the Share as uid 0/root.", "Declare dsm_user and use ${NAME_UID}:${NAME_GID} as the Compose user.", "warning"));
864
- continue;
865
- }
866
- if (NUMERIC_USER.test(user)) {
867
- errors.push(makeError("COMPOSE_USER_NUMERIC_FORBIDDEN", span, "The contract forbids hard-coded numeric uids.", "Use ${NAME_UID}:${NAME_GID} from the install dsm_user parameter."));
868
- }
869
- }
870
- return errors;
871
- }
872
- /** The gateway is the only door: the container's web port must not be reachable except through it. */
873
- export function validateGatewayPortLoopback(adminport, compose, composeFile) {
874
- return listPortExposures(compose)
875
- .filter((exposure) => exposure.hostPort === adminport && !exposure.loopbackOnly)
876
- .map((exposure) => makeError("COMPOSE_GATEWAY_PORT_EXPOSED", composeSpanForPath(compose, composeFile, `services.${exposure.service}.ports`), `The port ${exposure.entry} of ${exposure.service} binds to every network interface, so the container is reachable without DSM verification.`, `Bind it to loopback only: \"127.0.0.1:${adminport}:${exposure.containerPort ?? adminport}\", or choose public explicitly in integration.web.access in synology-app.yaml.`));
877
- }
878
- export function validateAdminPortMapping(adminport, compose, composeFile) {
879
- const published = collectPublishedTcpPorts(compose);
880
- const services = published.get(adminport) ?? [];
881
- if (services.length !== 1) {
882
- return [
883
- makeError("CONTRACT_ADMINPORT_UNMAPPED", composeSpanForPath(compose, composeFile, `adminport:${adminport}`), `adminport ${adminport} must map to exactly one published TCP port in Compose.`, "Publish the same host port as adminport in compose.yaml and make sure only one service uses it."),
884
- ];
885
- }
886
- return [];
887
- }