@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.
- package/CHANGELOG.md +20 -1
- package/README.md +33 -18
- package/dist/build-spk/generator.d.ts +3 -2
- package/dist/build-spk/generator.js +6 -846
- package/dist/build-spk/resource-worker.d.ts +2 -14
- package/dist/build-spk/resource-worker.js +3 -166
- package/dist/cli.js +208 -56
- package/dist/compose/analyze.d.ts +3 -61
- package/dist/compose/analyze.js +35 -776
- package/dist/compose/env.d.ts +5 -3
- package/dist/compose/env.js +17 -25
- package/dist/compose/runner.js +23 -6
- package/dist/contract/unsupported-types.js +1 -1
- package/dist/contract/validate.js +2 -2
- package/dist/contract/web-launch.d.ts +1 -12
- package/dist/contract/web-launch.js +10 -74
- package/dist/ctl/cli.d.ts +36 -0
- package/dist/ctl/cli.js +218 -0
- package/dist/ctl/descriptor.d.ts +81 -0
- package/dist/ctl/descriptor.js +93 -0
- package/dist/ctl/dsm-auth.d.ts +10 -0
- package/dist/ctl/dsm-auth.js +30 -0
- package/dist/ctl/errors.d.ts +17 -0
- package/dist/ctl/errors.js +32 -0
- package/dist/ctl/flows.d.ts +11 -0
- package/dist/ctl/flows.js +52 -0
- package/dist/ctl/input.d.ts +20 -0
- package/dist/ctl/input.js +128 -0
- package/dist/ctl/json.d.ts +2 -0
- package/dist/ctl/json.js +38 -0
- package/dist/ctl/redaction.d.ts +15 -0
- package/dist/ctl/redaction.js +198 -0
- package/dist/ctl/service.d.ts +38 -0
- package/dist/ctl/service.js +156 -0
- package/dist/ctl/store.d.ts +51 -0
- package/dist/ctl/store.js +93 -0
- package/dist/ctl/transfers.d.ts +14 -0
- package/dist/ctl/transfers.js +74 -0
- package/dist/ctl/transport.d.ts +11 -0
- package/dist/ctl/transport.js +79 -0
- package/dist/deploy/deploy.d.ts +4 -3
- package/dist/deploy/deploy.js +5 -210
- package/dist/deploy/plan.js +26 -7
- package/dist/dev.js +5 -4
- package/dist/e2e/dsm.d.ts +2 -2
- package/dist/e2e/dsm.js +10 -6
- package/dist/error-registry.js +1020 -834
- package/dist/host/share-permission-lookup.d.ts +1 -1
- package/dist/host/share-permission.d.ts +1 -1
- package/dist/host/share-permission.js +1 -1
- package/dist/info.js +120 -28
- package/dist/init.js +188 -64
- package/dist/inspect.js +21 -6
- package/dist/runtime/executor-digests.json +5 -5
- package/dist/skill.js +51 -9
- package/dist/standalone/api-description-inspect.d.ts +4 -0
- package/dist/standalone/api-description-inspect.js +12 -0
- package/dist/standalone/api-description.d.ts +7 -0
- package/dist/standalone/api-description.js +73 -0
- package/dist/standalone/build.d.ts +3 -0
- package/dist/standalone/build.js +140 -33
- package/dist/standalone/compose-errors.d.ts +3 -0
- package/dist/standalone/compose-errors.js +243 -0
- package/dist/standalone/compose.d.ts +97 -10
- package/dist/standalone/compose.js +306 -75
- package/dist/standalone/daemon.js +111 -29
- package/dist/standalone/dsm-window.js +39 -8
- package/dist/standalone/identity-gateway.d.ts +9 -2
- package/dist/standalone/identity-gateway.js +282 -66
- package/dist/standalone/inspect-spk.d.ts +12 -0
- package/dist/standalone/inspect-spk.js +666 -136
- package/dist/standalone/lifecycle.js +124 -42
- package/dist/standalone/manifest.d.ts +8 -1
- package/dist/standalone/manifest.js +303 -86
- package/dist/standalone/resource.js +6 -2
- package/dist/standalone/spk.d.ts +4 -1
- package/dist/standalone/spk.js +138 -39
- package/dist/template-paths.d.ts +0 -2
- package/dist/template-paths.js +0 -5
- package/dist/test-command.d.ts +1 -1
- package/dist/test-command.js +1 -1
- package/dist/validate.d.ts +3 -3
- package/dist/validate.js +32 -140
- package/package.json +5 -3
- package/schema/synopkgland-api-v1.schema.json +423 -0
- package/skills/SKILL.md +4 -2
- package/skills/reference/compose.md +349 -0
- package/skills/reference/create.md +9 -18
- package/skills/reference/ctl.md +41 -0
- package/skills/reference/deploy.md +1 -1
- package/skills/reference/files.md +5 -1
- package/skills/reference/identity-gateway.md +8 -144
- package/skills/reference/wrap.md +10 -39
- package/assets/go/daemon/fixture_linux_test.go +0 -15
- package/assets/go/daemon/fixture_other_test.go +0 -7
- package/assets/go/daemon/go.mod +0 -5
- package/assets/go/daemon/main.go +0 -636
- package/assets/go/daemon/main_test.go +0 -315
- package/assets/go/daemon/network.go +0 -107
- package/assets/go/daemon/network_test.go +0 -86
- package/assets/go/daemon/process_linux.go +0 -55
- package/assets/go/daemon/process_linux_test.go +0 -161
- package/assets/go/daemon/process_other.go +0 -14
- package/assets/go/daemon/testdata/child.go +0 -44
- package/assets/go/gateway/auth.go +0 -101
- package/assets/go/gateway/auth_test.go +0 -216
- package/assets/go/gateway/cgi.go +0 -133
- package/assets/go/gateway/cgi_test.go +0 -156
- package/assets/go/gateway/csrf.go +0 -73
- package/assets/go/gateway/csrf_test.go +0 -155
- package/assets/go/gateway/directory.go +0 -108
- package/assets/go/gateway/directory_test.go +0 -202
- package/assets/go/gateway/gateway.go +0 -412
- package/assets/go/gateway/gateway_test.go +0 -676
- package/assets/go/gateway/go.mod +0 -5
- package/assets/go/gateway/groups.go +0 -66
- package/assets/go/gateway/main.go +0 -45
- package/dist/build-spk/gateway.d.ts +0 -31
- package/dist/build-spk/gateway.js +0 -60
- package/dist/compose/privilege.d.ts +0 -14
- package/dist/compose/privilege.js +0 -102
package/dist/compose/analyze.js
CHANGED
|
@@ -1,49 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
1
|
+
const DIGEST_PINNED_IMAGE = /@sha256:[a-f0-9]{64}$/;
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { parseDocument } from "yaml";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
30
|
-
|
|
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 {
|
|
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
|
|
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"
|
|
102
|
-
|
|
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 &&
|
|
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 &&
|
|
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({
|
|
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
|
|
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
|
-
}
|